mirror of
https://github.com/rafaelvcaetano/melonDS-android.git
synced 2024-11-26 23:20:40 +00:00
Allow DSiWare data files to be imported and exported
This commit is contained in:
parent
32b1721a51
commit
337405914e
@ -21,6 +21,8 @@
|
||||
#define TITLE_IMPORT_TITLE_ALREADY_IMPORTED 4
|
||||
#define TITLE_IMPORT_INSATLL_FAILED 5
|
||||
|
||||
const u32 DSI_NAND_FILE_CATEGORY = 0x00030004;
|
||||
|
||||
bool isNandOpen = false;
|
||||
|
||||
jobject getTitleData(JNIEnv* env, u32 category, u32 titleId);
|
||||
@ -61,7 +63,7 @@ Java_me_magnum_melonds_MelonDSiNand_openNand(JNIEnv* env, jobject thiz, jobject
|
||||
JNIEXPORT jobject JNICALL
|
||||
Java_me_magnum_melonds_MelonDSiNand_listTitles(JNIEnv* env, jobject thiz)
|
||||
{
|
||||
const u32 category = 0x00030004;
|
||||
const u32 category = DSI_NAND_FILE_CATEGORY;
|
||||
std::vector<u32> titleList;
|
||||
DSi_NAND::ListTitles(category, titleList);
|
||||
|
||||
@ -104,7 +106,7 @@ Java_me_magnum_melonds_MelonDSiNand_importTitle(JNIEnv* env, jobject thiz, jstri
|
||||
fread(titleId, 8, 1, titleFile);
|
||||
fclose(titleFile);
|
||||
|
||||
if (titleId[1] != 0x00030004)
|
||||
if (titleId[1] != DSI_NAND_FILE_CATEGORY)
|
||||
{
|
||||
// Not a DSiWare title
|
||||
env->ReleaseStringUTFChars(titleUri, titlePath);
|
||||
@ -136,7 +138,39 @@ Java_me_magnum_melonds_MelonDSiNand_importTitle(JNIEnv* env, jobject thiz, jstri
|
||||
JNIEXPORT void JNICALL
|
||||
Java_me_magnum_melonds_MelonDSiNand_deleteTitle(JNIEnv* env, jobject thiz, jint titleId)
|
||||
{
|
||||
DSi_NAND::DeleteTitle(0x00030004, (u32) titleId);
|
||||
DSi_NAND::DeleteTitle(DSI_NAND_FILE_CATEGORY, (u32) titleId);
|
||||
}
|
||||
|
||||
JNIEXPORT jboolean JNICALL
|
||||
Java_me_magnum_melonds_MelonDSiNand_importTitleFile(JNIEnv* env, jobject thiz, jint titleId, jint fileType, jstring fileUri)
|
||||
{
|
||||
jboolean isFilePathCopy;
|
||||
const char* filePath = env->GetStringUTFChars(fileUri, &isFilePathCopy);
|
||||
|
||||
bool result = DSi_NAND::ImportTitleData(DSI_NAND_FILE_CATEGORY, (u32) titleId, fileType, filePath);
|
||||
|
||||
if (isFilePathCopy)
|
||||
{
|
||||
env->ReleaseStringUTFChars(fileUri, filePath);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
JNIEXPORT jboolean JNICALL
|
||||
Java_me_magnum_melonds_MelonDSiNand_exportTitleFile(JNIEnv* env, jobject thiz, jint titleId, jint fileType, jstring fileUri)
|
||||
{
|
||||
jboolean isFilePathCopy;
|
||||
const char* filePath = env->GetStringUTFChars(fileUri, &isFilePathCopy);
|
||||
|
||||
bool result = DSi_NAND::ExportTitleData(DSI_NAND_FILE_CATEGORY, (u32) titleId, fileType, filePath);
|
||||
|
||||
if (isFilePathCopy)
|
||||
{
|
||||
env->ReleaseStringUTFChars(fileUri, filePath);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
JNIEXPORT void JNICALL
|
||||
@ -168,7 +202,7 @@ jobject getTitleData(JNIEnv* env, u32 category, u32 titleId)
|
||||
env->ReleaseByteArrayElements(iconBytes, iconArrayElements, 0);
|
||||
|
||||
jclass dsiWareTitleClass = env->FindClass("me/magnum/melonds/domain/model/DSiWareTitle");
|
||||
jmethodID dsiWareTitleConstructor = env->GetMethodID(dsiWareTitleClass, "<init>", "(Ljava/lang/String;Ljava/lang/String;J[B)V");
|
||||
jmethodID dsiWareTitleConstructor = env->GetMethodID(dsiWareTitleClass, "<init>", "(Ljava/lang/String;Ljava/lang/String;J[BJJI)V");
|
||||
|
||||
std::wstring_convert<std::codecvt_utf8_utf16<char16_t>, char16_t> convert;
|
||||
std::string englishTitle = convert.to_bytes(banner.EnglishTitle);
|
||||
@ -177,6 +211,16 @@ jobject getTitleData(JNIEnv* env, u32 category, u32 titleId)
|
||||
std::string title = englishTitle.substr(0, pos);
|
||||
std::string producer = englishTitle.substr(pos + 1);
|
||||
|
||||
jobject titleObject = env->NewObject(dsiWareTitleClass, dsiWareTitleConstructor, env->NewStringUTF(title.c_str()), env->NewStringUTF(producer.c_str()), (jlong) titleId, iconBytes);
|
||||
jobject titleObject = env->NewObject(
|
||||
dsiWareTitleClass,
|
||||
dsiWareTitleConstructor,
|
||||
env->NewStringUTF(title.c_str()),
|
||||
env->NewStringUTF(producer.c_str()),
|
||||
(jlong) titleId,
|
||||
iconBytes,
|
||||
(jlong) header.DSiPublicSavSize,
|
||||
(jlong) header.DSiPrivateSavSize,
|
||||
header.AppFlags
|
||||
);
|
||||
return titleObject;
|
||||
}
|
@ -8,5 +8,7 @@ object MelonDSiNand {
|
||||
external fun listTitles(): ArrayList<DSiWareTitle>
|
||||
external fun importTitle(titleUri: String, tmdMetadata: ByteArray): Int
|
||||
external fun deleteTitle(titleId: Int)
|
||||
external fun importTitleFile(titleId: Int, fileType: Int, fileUri: String): Boolean
|
||||
external fun exportTitleFile(titleId: Int, fileType: Int, fileUri: String): Boolean
|
||||
external fun closeNand()
|
||||
}
|
@ -0,0 +1,24 @@
|
||||
package me.magnum.melonds.common.contracts
|
||||
|
||||
import android.app.Activity
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.net.Uri
|
||||
import androidx.activity.result.contract.ActivityResultContract
|
||||
|
||||
class CreateFileContract : ActivityResultContract<String, Uri?>() {
|
||||
override fun createIntent(context: Context, input: String): Intent {
|
||||
return Intent(Intent.ACTION_CREATE_DOCUMENT)
|
||||
.putExtra(Intent.EXTRA_TITLE, input)
|
||||
.addCategory(Intent.CATEGORY_OPENABLE)
|
||||
.setType("application/octet-stream")
|
||||
}
|
||||
|
||||
override fun parseResult(resultCode: Int, intent: Intent?): Uri? {
|
||||
return if (intent == null || resultCode != Activity.RESULT_OK) {
|
||||
null
|
||||
} else {
|
||||
intent.data
|
||||
}
|
||||
}
|
||||
}
|
@ -31,10 +31,6 @@ class DirectoryPickerContract(private val permissions: Permission) : ActivityRes
|
||||
return intent
|
||||
}
|
||||
|
||||
override fun getSynchronousResult(context: Context, input: Uri?): SynchronousResult<Uri?>? {
|
||||
return null
|
||||
}
|
||||
|
||||
override fun parseResult(resultCode: Int, intent: Intent?): Uri? {
|
||||
return if (intent == null || resultCode != Activity.RESULT_OK) {
|
||||
null
|
||||
|
@ -21,6 +21,7 @@ class FilePickerContract(private val permission: Permission) : ActivityResultCon
|
||||
val intent = Intent(Intent.ACTION_OPEN_DOCUMENT)
|
||||
.putExtra(Intent.EXTRA_MIME_TYPES, input.second ?: arrayOf("*/*"))
|
||||
.setType("*/*")
|
||||
.addCategory(Intent.CATEGORY_OPENABLE)
|
||||
.addFlags(permission.toFlags())
|
||||
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O && input.first != null) {
|
||||
@ -30,10 +31,6 @@ class FilePickerContract(private val permission: Permission) : ActivityResultCon
|
||||
return intent
|
||||
}
|
||||
|
||||
override fun getSynchronousResult(context: Context, input: Pair<Uri?, Array<String>?>): SynchronousResult<Uri?>? {
|
||||
return null
|
||||
}
|
||||
|
||||
override fun parseResult(resultCode: Int, intent: Intent?): Uri? {
|
||||
return if (intent == null || resultCode != Activity.RESULT_OK) {
|
||||
null
|
||||
|
@ -5,4 +5,14 @@ class DSiWareTitle(
|
||||
val producer: String,
|
||||
val titleId: Long,
|
||||
val icon: ByteArray,
|
||||
)
|
||||
val publicSavSize: Long,
|
||||
val privateSavSize: Long,
|
||||
val appFlags: Int,
|
||||
) {
|
||||
|
||||
fun hasPublicSavFile() = publicSavSize != 0L
|
||||
|
||||
fun hasPrivateSavFile() = privateSavSize != 0L
|
||||
|
||||
fun hasBannerSavFile() = (appFlags and (0x04)) != 0
|
||||
}
|
@ -0,0 +1,7 @@
|
||||
package me.magnum.melonds.domain.model.dsinand
|
||||
|
||||
enum class DSiWareTitleFileType(val fileName: String) {
|
||||
PUBLIC_SAV("public.sav"),
|
||||
PRIVATE_SAV("private.sav"),
|
||||
BANNER_SAV("banner.sav"),
|
||||
}
|
@ -4,11 +4,14 @@ import android.net.Uri
|
||||
import me.magnum.melonds.domain.model.DSiWareTitle
|
||||
import me.magnum.melonds.domain.model.dsinand.ImportDSiWareTitleResult
|
||||
import me.magnum.melonds.domain.model.dsinand.OpenDSiNandResult
|
||||
import me.magnum.melonds.domain.model.dsinand.DSiWareTitleFileType
|
||||
|
||||
interface DSiNandManager {
|
||||
suspend fun openNand(): OpenDSiNandResult
|
||||
suspend fun listTitles(): List<DSiWareTitle>
|
||||
suspend fun importTitle(titleUri: Uri): ImportDSiWareTitleResult
|
||||
suspend fun deleteTitle(title: DSiWareTitle)
|
||||
suspend fun importTitleFile(title: DSiWareTitle, fileType: DSiWareTitleFileType, fileUri: Uri): Boolean
|
||||
suspend fun exportTitleFile(title: DSiWareTitle, fileType: DSiWareTitleFileType, fileUri: Uri): Boolean
|
||||
fun closeNand()
|
||||
}
|
@ -8,6 +8,7 @@ import me.magnum.melonds.MelonDSiNand
|
||||
import me.magnum.melonds.common.suspendRunCatching
|
||||
import me.magnum.melonds.domain.model.ConfigurationDirResult
|
||||
import me.magnum.melonds.domain.model.DSiWareTitle
|
||||
import me.magnum.melonds.domain.model.dsinand.DSiWareTitleFileType
|
||||
import me.magnum.melonds.domain.model.dsinand.ImportDSiWareTitleResult
|
||||
import me.magnum.melonds.domain.model.dsinand.OpenDSiNandResult
|
||||
import me.magnum.melonds.domain.repositories.DSiWareMetadataRepository
|
||||
@ -90,6 +91,22 @@ class AndroidDSiNandManager(
|
||||
MelonDSiNand.deleteTitle((title.titleId and 0xFFFFFFFF).toInt())
|
||||
}
|
||||
|
||||
override suspend fun importTitleFile(title: DSiWareTitle, fileType: DSiWareTitleFileType, fileUri: Uri): Boolean {
|
||||
if (!isNandOpen.get()) {
|
||||
return false
|
||||
}
|
||||
|
||||
return MelonDSiNand.importTitleFile((title.titleId and 0xFFFFFFFF).toInt(), fileType.ordinal, fileUri.toString())
|
||||
}
|
||||
|
||||
override suspend fun exportTitleFile(title: DSiWareTitle, fileType: DSiWareTitleFileType, fileUri: Uri): Boolean {
|
||||
if (!isNandOpen.get()) {
|
||||
return false
|
||||
}
|
||||
|
||||
return MelonDSiNand.exportTitleFile((title.titleId and 0xFFFFFFFF).toInt(), fileType.ordinal, fileUri.toString())
|
||||
}
|
||||
|
||||
override fun closeNand() {
|
||||
if (!isNandOpen.compareAndSet(true, false)) {
|
||||
return
|
||||
|
@ -18,7 +18,10 @@ import dagger.hilt.android.AndroidEntryPoint
|
||||
import kotlinx.coroutines.flow.collectLatest
|
||||
import me.magnum.melonds.R
|
||||
import me.magnum.melonds.domain.model.dsinand.ImportDSiWareTitleResult
|
||||
import me.magnum.melonds.ui.dsiwaremanager.model.ImportExportDSiWareTitleFileEvent
|
||||
import me.magnum.melonds.ui.dsiwaremanager.ui.DSiWareManager
|
||||
import me.magnum.melonds.ui.dsiwaremanager.ui.rememberDSiWareTitleExportFilePicker
|
||||
import me.magnum.melonds.ui.dsiwaremanager.ui.rememberDSiWareTitleImportFilePicker
|
||||
import me.magnum.melonds.ui.theme.MelonTheme
|
||||
|
||||
@AndroidEntryPoint
|
||||
@ -35,13 +38,22 @@ class DSiWareManagerActivity : AppCompatActivity() {
|
||||
val state = viewModel.state.collectAsState()
|
||||
val importingTitle = viewModel.importingTitle.collectAsState(false)
|
||||
|
||||
val importTitleFilePickLauncher = rememberDSiWareTitleImportFilePicker(
|
||||
onFilePicked = viewModel::importDSiWareTitleFile,
|
||||
)
|
||||
val exportTitleFilePickLauncher = rememberDSiWareTitleExportFilePicker(
|
||||
onFilePicked = viewModel::exportDSiWareTitleFile,
|
||||
)
|
||||
|
||||
DSiWareManager(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
state = state.value,
|
||||
onImportTitle = { viewModel.importTitleToNand(it) },
|
||||
onDeleteTitle = { viewModel.deleteTitle(it) },
|
||||
onBiosConfigurationFinished = { viewModel.revalidateBiosConfiguration() },
|
||||
retrieveTitleIcon = { viewModel.getTitleIcon(it) },
|
||||
onImportTitle = viewModel::importTitleToNand,
|
||||
onDeleteTitle = viewModel::deleteTitle,
|
||||
onImportTitleFile = { title, fileType -> importTitleFilePickLauncher.launch(title, fileType) },
|
||||
onExportTitleFile = { title, fileType -> exportTitleFilePickLauncher.launch(title, fileType) },
|
||||
onBiosConfigurationFinished = viewModel::revalidateBiosConfiguration,
|
||||
retrieveTitleIcon = viewModel::getTitleIcon,
|
||||
)
|
||||
|
||||
if (importingTitle.value) {
|
||||
@ -58,6 +70,12 @@ class DSiWareManagerActivity : AppCompatActivity() {
|
||||
Toast.makeText(this@DSiWareManagerActivity, getImportTitleResultMessage(it), Toast.LENGTH_LONG).show()
|
||||
}
|
||||
}
|
||||
|
||||
LaunchedEffect(null) {
|
||||
viewModel.importExportFileEvent.collectLatest {
|
||||
Toast.makeText(this@DSiWareManagerActivity, getImportExportFileErrorMessage(it), Toast.LENGTH_SHORT).show()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -75,4 +93,13 @@ class DSiWareManagerActivity : AppCompatActivity() {
|
||||
ImportDSiWareTitleResult.UNKNOWN -> getString(R.string.dsiware_manager_import_title_error_unknown)
|
||||
}
|
||||
}
|
||||
|
||||
private fun getImportExportFileErrorMessage(result: ImportExportDSiWareTitleFileEvent): String {
|
||||
return when (result) {
|
||||
is ImportExportDSiWareTitleFileEvent.ImportSuccess -> getString(R.string.dsiware_manager_import_file_success, result.fileName)
|
||||
is ImportExportDSiWareTitleFileEvent.ImportError -> getString(R.string.dsiware_manager_import_file_error)
|
||||
is ImportExportDSiWareTitleFileEvent.ExportSuccess -> getString(R.string.dsiware_manager_export_file_success, result.fileName)
|
||||
is ImportExportDSiWareTitleFileEvent.ExportError -> getString(R.string.dsiware_manager_export_file_error)
|
||||
}
|
||||
}
|
||||
}
|
@ -18,6 +18,8 @@ import me.magnum.melonds.domain.repositories.SettingsRepository
|
||||
import me.magnum.melonds.domain.services.ConfigurationDirectoryVerifier
|
||||
import me.magnum.melonds.domain.services.DSiNandManager
|
||||
import me.magnum.melonds.ui.dsiwaremanager.model.DSiWareManagerUiState
|
||||
import me.magnum.melonds.domain.model.dsinand.DSiWareTitleFileType
|
||||
import me.magnum.melonds.ui.dsiwaremanager.model.ImportExportDSiWareTitleFileEvent
|
||||
import me.magnum.melonds.ui.romlist.RomIcon
|
||||
import java.nio.ByteBuffer
|
||||
import javax.inject.Inject
|
||||
@ -38,6 +40,9 @@ class DSiWareManagerViewModel @Inject constructor(
|
||||
private val _importTitleError = MutableSharedFlow<ImportDSiWareTitleResult>(extraBufferCapacity = 1, onBufferOverflow = BufferOverflow.DROP_OLDEST)
|
||||
val importTitleError: SharedFlow<ImportDSiWareTitleResult> = _importTitleError.asSharedFlow()
|
||||
|
||||
private val _importExportFileEvent = MutableSharedFlow<ImportExportDSiWareTitleFileEvent>(extraBufferCapacity = 1, onBufferOverflow = BufferOverflow.DROP_OLDEST)
|
||||
val importExportFileEvent: SharedFlow<ImportExportDSiWareTitleFileEvent> = _importExportFileEvent.asSharedFlow()
|
||||
|
||||
init {
|
||||
loadDSiWareData()
|
||||
}
|
||||
@ -69,6 +74,38 @@ class DSiWareManagerViewModel @Inject constructor(
|
||||
}
|
||||
}
|
||||
|
||||
fun importDSiWareTitleFile(title: DSiWareTitle, fileType: DSiWareTitleFileType, fileUri: Uri) {
|
||||
_importingTitle.value = true
|
||||
|
||||
viewModelScope.launch {
|
||||
withContext(Dispatchers.Default) {
|
||||
val success = dsiNandManager.importTitleFile(title, fileType, fileUri)
|
||||
if (success) {
|
||||
_importExportFileEvent.tryEmit(ImportExportDSiWareTitleFileEvent.ImportSuccess(fileType.fileName))
|
||||
} else {
|
||||
_importExportFileEvent.tryEmit(ImportExportDSiWareTitleFileEvent.ImportError)
|
||||
}
|
||||
_importingTitle.value = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun exportDSiWareTitleFile(title: DSiWareTitle, fileType: DSiWareTitleFileType, fileUri: Uri) {
|
||||
_importingTitle.value = true
|
||||
|
||||
viewModelScope.launch {
|
||||
withContext(Dispatchers.Default) {
|
||||
val success = dsiNandManager.exportTitleFile(title, fileType, fileUri)
|
||||
if (success) {
|
||||
_importExportFileEvent.tryEmit(ImportExportDSiWareTitleFileEvent.ExportSuccess(fileType.fileName))
|
||||
} else {
|
||||
_importExportFileEvent.tryEmit(ImportExportDSiWareTitleFileEvent.ExportError)
|
||||
}
|
||||
_importingTitle.value = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun getTitleIcon(title: DSiWareTitle): RomIcon {
|
||||
val bitmap = createBitmap(32, 32).apply {
|
||||
copyPixelsFromBuffer(ByteBuffer.wrap(title.icon))
|
||||
|
@ -0,0 +1,8 @@
|
||||
package me.magnum.melonds.ui.dsiwaremanager.model
|
||||
|
||||
enum class DSiWareItemDropdownMenu {
|
||||
NONE,
|
||||
MAIN,
|
||||
IMPORT,
|
||||
EXPORT,
|
||||
}
|
@ -0,0 +1,8 @@
|
||||
package me.magnum.melonds.ui.dsiwaremanager.model
|
||||
|
||||
sealed class ImportExportDSiWareTitleFileEvent {
|
||||
data class ImportSuccess(val fileName: String) : ImportExportDSiWareTitleFileEvent()
|
||||
data object ImportError : ImportExportDSiWareTitleFileEvent()
|
||||
data class ExportSuccess(val fileName: String) : ImportExportDSiWareTitleFileEvent()
|
||||
data object ExportError : ImportExportDSiWareTitleFileEvent()
|
||||
}
|
@ -2,17 +2,19 @@ package me.magnum.melonds.ui.dsiwaremanager.ui
|
||||
|
||||
import android.content.res.Configuration.UI_MODE_NIGHT_YES
|
||||
import androidx.compose.foundation.Image
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.focusable
|
||||
import androidx.compose.foundation.interaction.MutableInteractionSource
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.material.Divider
|
||||
import androidx.compose.material.DropdownMenu
|
||||
import androidx.compose.material.DropdownMenuItem
|
||||
import androidx.compose.material.Icon
|
||||
import androidx.compose.material.IconButton
|
||||
import androidx.compose.material.MaterialTheme
|
||||
import androidx.compose.material.Text
|
||||
import androidx.compose.material.ripple.rememberRipple
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment.Companion.CenterVertically
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.FilterQuality
|
||||
@ -20,6 +22,7 @@ import androidx.compose.ui.graphics.ImageBitmap
|
||||
import androidx.compose.ui.graphics.asImageBitmap
|
||||
import androidx.compose.ui.graphics.drawscope.DrawScope
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.dp
|
||||
@ -30,6 +33,8 @@ import me.magnum.melonds.R
|
||||
import me.magnum.melonds.domain.model.DSiWareTitle
|
||||
import me.magnum.melonds.domain.model.RomIconFiltering
|
||||
import me.magnum.melonds.ui.common.component.text.CaptionText
|
||||
import me.magnum.melonds.ui.dsiwaremanager.model.DSiWareItemDropdownMenu
|
||||
import me.magnum.melonds.domain.model.dsinand.DSiWareTitleFileType
|
||||
import me.magnum.melonds.ui.romlist.RomIcon
|
||||
import me.magnum.melonds.ui.theme.MelonTheme
|
||||
|
||||
@ -38,16 +43,27 @@ fun DSiWareItem(
|
||||
modifier: Modifier,
|
||||
item: DSiWareTitle,
|
||||
onDeleteClicked: () -> Unit,
|
||||
onImportFile: (DSiWareTitleFileType) -> Unit,
|
||||
onExportFile: (DSiWareTitleFileType) -> Unit,
|
||||
retrieveTitleIcon: () -> RomIcon,
|
||||
) {
|
||||
var dropdownMenu by remember(item) {
|
||||
mutableStateOf(DSiWareItemDropdownMenu.NONE)
|
||||
}
|
||||
|
||||
Column(modifier) {
|
||||
Row(Modifier.height(IntrinsicSize.Min).padding(start = 8.dp, top = 8.dp, bottom = 8.dp)) {
|
||||
Row(
|
||||
Modifier
|
||||
.height(IntrinsicSize.Min)
|
||||
.padding(start = 8.dp, top = 8.dp, bottom = 8.dp)) {
|
||||
val icon = remember(item.titleId) {
|
||||
retrieveTitleIcon()
|
||||
}
|
||||
|
||||
Image(
|
||||
modifier = Modifier.size(48.dp).align(CenterVertically),
|
||||
modifier = Modifier
|
||||
.size(48.dp)
|
||||
.align(CenterVertically),
|
||||
bitmap = icon.bitmap?.asImageBitmap() ?: ImageBitmap(1, 1),
|
||||
contentDescription = null,
|
||||
filterQuality = when (icon.filtering) {
|
||||
@ -57,7 +73,9 @@ fun DSiWareItem(
|
||||
)
|
||||
Spacer(Modifier.width(8.dp))
|
||||
Column(
|
||||
modifier = Modifier.weight(1f).fillMaxHeight(),
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
.fillMaxHeight(),
|
||||
verticalArrangement = Arrangement.SpaceBetween
|
||||
) {
|
||||
Text(
|
||||
@ -71,26 +89,119 @@ fun DSiWareItem(
|
||||
style = MaterialTheme.typography.body2,
|
||||
)
|
||||
}
|
||||
Icon(
|
||||
modifier = Modifier
|
||||
.size(48.dp)
|
||||
.align(CenterVertically)
|
||||
.padding(8.dp)
|
||||
.focusable()
|
||||
.clickable(
|
||||
interactionSource = remember { MutableInteractionSource() },
|
||||
onClick = onDeleteClicked,
|
||||
indication = rememberRipple(bounded = false),
|
||||
),
|
||||
painter = painterResource(id = R.drawable.ic_clear),
|
||||
contentDescription = "Delete",
|
||||
tint = MaterialTheme.colors.onSurface,
|
||||
)
|
||||
IconButton(onClick = { dropdownMenu = DSiWareItemDropdownMenu.MAIN }) {
|
||||
Icon(
|
||||
modifier = Modifier.size(32.dp),
|
||||
painter = painterResource(id = R.drawable.ic_menu),
|
||||
contentDescription = stringResource(id = R.string.delete),
|
||||
tint = MaterialTheme.colors.onSurface,
|
||||
)
|
||||
|
||||
ItemDropdownMenu(
|
||||
item = item,
|
||||
menu = dropdownMenu,
|
||||
onOpenMenu = { dropdownMenu = it },
|
||||
onDeleteItem = onDeleteClicked,
|
||||
onImportFile = {
|
||||
dropdownMenu = DSiWareItemDropdownMenu.NONE
|
||||
onImportFile(it)
|
||||
},
|
||||
onExportFile = {
|
||||
dropdownMenu = DSiWareItemDropdownMenu.NONE
|
||||
onExportFile(it)
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
}
|
||||
Divider()
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ItemDropdownMenu(
|
||||
item: DSiWareTitle,
|
||||
menu: DSiWareItemDropdownMenu,
|
||||
onOpenMenu: (DSiWareItemDropdownMenu) -> Unit,
|
||||
onDeleteItem: () -> Unit,
|
||||
onImportFile: (DSiWareTitleFileType) -> Unit,
|
||||
onExportFile: (DSiWareTitleFileType) -> Unit,
|
||||
) {
|
||||
when (menu) {
|
||||
DSiWareItemDropdownMenu.NONE -> { /* no-op */ }
|
||||
DSiWareItemDropdownMenu.MAIN -> {
|
||||
DropdownMenu(
|
||||
expanded = true,
|
||||
onDismissRequest = { onOpenMenu(DSiWareItemDropdownMenu.NONE) },
|
||||
) {
|
||||
DropdownMenuItem(onClick = { onOpenMenu(DSiWareItemDropdownMenu.IMPORT) }) {
|
||||
Text(text = stringResource(id = R.string.dsiware_manager_import_data))
|
||||
}
|
||||
DropdownMenuItem(onClick = { onOpenMenu(DSiWareItemDropdownMenu.EXPORT) }) {
|
||||
Text(text = stringResource(id = R.string.dsiware_manager_export_data))
|
||||
}
|
||||
DropdownMenuItem(onClick = onDeleteItem) {
|
||||
Text(text = stringResource(id = R.string.delete))
|
||||
}
|
||||
}
|
||||
}
|
||||
DSiWareItemDropdownMenu.IMPORT -> {
|
||||
DropdownMenu(
|
||||
expanded = true,
|
||||
onDismissRequest = { onOpenMenu(DSiWareItemDropdownMenu.NONE) },
|
||||
) {
|
||||
FileTypeDropdownItem(
|
||||
fileType = DSiWareTitleFileType.PUBLIC_SAV,
|
||||
enabled = item.hasPublicSavFile(),
|
||||
onClick = { onImportFile(DSiWareTitleFileType.PUBLIC_SAV) },
|
||||
)
|
||||
FileTypeDropdownItem(
|
||||
fileType = DSiWareTitleFileType.PRIVATE_SAV,
|
||||
enabled = item.hasPrivateSavFile(),
|
||||
onClick = { onImportFile(DSiWareTitleFileType.PRIVATE_SAV) },
|
||||
)
|
||||
FileTypeDropdownItem(
|
||||
fileType = DSiWareTitleFileType.BANNER_SAV,
|
||||
enabled = item.hasBannerSavFile(),
|
||||
onClick = { onImportFile(DSiWareTitleFileType.BANNER_SAV) },
|
||||
)
|
||||
}
|
||||
}
|
||||
DSiWareItemDropdownMenu.EXPORT -> {
|
||||
DropdownMenu(
|
||||
expanded = true,
|
||||
onDismissRequest = { onOpenMenu(DSiWareItemDropdownMenu.NONE) },
|
||||
) {
|
||||
FileTypeDropdownItem(
|
||||
fileType = DSiWareTitleFileType.PUBLIC_SAV,
|
||||
enabled = item.hasPublicSavFile(),
|
||||
onClick = { onExportFile(DSiWareTitleFileType.PUBLIC_SAV) },
|
||||
)
|
||||
FileTypeDropdownItem(
|
||||
fileType = DSiWareTitleFileType.PRIVATE_SAV,
|
||||
enabled = item.hasPrivateSavFile(),
|
||||
onClick = { onExportFile(DSiWareTitleFileType.PRIVATE_SAV) },
|
||||
)
|
||||
FileTypeDropdownItem(
|
||||
fileType = DSiWareTitleFileType.BANNER_SAV,
|
||||
enabled = item.hasBannerSavFile(),
|
||||
onClick = { onExportFile(DSiWareTitleFileType.BANNER_SAV) },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun FileTypeDropdownItem(fileType: DSiWareTitleFileType, enabled: Boolean, onClick: () -> Unit) {
|
||||
DropdownMenuItem(
|
||||
onClick = onClick,
|
||||
enabled = enabled,
|
||||
) {
|
||||
Text(text = fileType.fileName)
|
||||
}
|
||||
}
|
||||
|
||||
@Preview(showBackground = true)
|
||||
@Preview(showBackground = true, uiMode = UI_MODE_NIGHT_YES)
|
||||
@Composable
|
||||
@ -100,9 +211,11 @@ private fun PreviewDSiWareItem() {
|
||||
MelonTheme {
|
||||
DSiWareItem(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
item = DSiWareTitle("Highway 4: Mediocre Racing", "Playpark", 0, ByteArray(0)),
|
||||
item = DSiWareTitle("Highway 4: Mediocre Racing", "Playpark", 0, ByteArray(0), 0, 0, 0),
|
||||
onDeleteClicked = { },
|
||||
retrieveTitleIcon = { RomIcon(bitmap, RomIconFiltering.NONE) }
|
||||
onImportFile = { },
|
||||
onExportFile = { },
|
||||
retrieveTitleIcon = { RomIcon(bitmap, RomIconFiltering.NONE) },
|
||||
)
|
||||
}
|
||||
}
|
@ -36,6 +36,7 @@ import me.magnum.melonds.ui.common.FabActionItem
|
||||
import me.magnum.melonds.ui.common.MultiActionFloatingActionButton
|
||||
import me.magnum.melonds.ui.common.melonButtonColors
|
||||
import me.magnum.melonds.ui.dsiwaremanager.model.DSiWareManagerUiState
|
||||
import me.magnum.melonds.domain.model.dsinand.DSiWareTitleFileType
|
||||
import me.magnum.melonds.ui.romlist.RomIcon
|
||||
import me.magnum.melonds.ui.settings.SettingsActivity
|
||||
import me.magnum.melonds.ui.theme.MelonTheme
|
||||
@ -49,6 +50,8 @@ fun DSiWareManager(
|
||||
state: DSiWareManagerUiState,
|
||||
onImportTitle: (Uri) -> Unit,
|
||||
onDeleteTitle: (DSiWareTitle) -> Unit,
|
||||
onImportTitleFile: (DSiWareTitle, DSiWareTitleFileType) -> Unit,
|
||||
onExportTitleFile: (DSiWareTitle, DSiWareTitleFileType) -> Unit,
|
||||
onBiosConfigurationFinished: () -> Unit,
|
||||
retrieveTitleIcon: (DSiWareTitle) -> RomIcon,
|
||||
) {
|
||||
@ -74,6 +77,8 @@ fun DSiWareManager(
|
||||
onImportTitleFromFile = { importTitleLauncher.launch(null to arrayOf("*/*")) },
|
||||
onImportTitleFromRomList = { onImportTitle(it.uri) },
|
||||
onDeleteTitle = onDeleteTitle,
|
||||
onImportTitleFile = onImportTitleFile,
|
||||
onExportTitleFile = onExportTitleFile,
|
||||
retrieveTitleIcon = retrieveTitleIcon,
|
||||
)
|
||||
}
|
||||
@ -150,6 +155,8 @@ private fun Ready(
|
||||
onImportTitleFromFile: () -> Unit,
|
||||
onImportTitleFromRomList: (Rom) -> Unit,
|
||||
onDeleteTitle: (DSiWareTitle) -> Unit,
|
||||
onImportTitleFile: (DSiWareTitle, DSiWareTitleFileType) -> Unit,
|
||||
onExportTitleFile: (DSiWareTitle, DSiWareTitleFileType) -> Unit,
|
||||
retrieveTitleIcon: (DSiWareTitle) -> RomIcon,
|
||||
) {
|
||||
val showingRomList = rememberSaveable(null) { mutableStateOf(false) }
|
||||
@ -167,6 +174,8 @@ private fun Ready(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
titles = titles,
|
||||
onDeleteTitle = onDeleteTitle,
|
||||
onImportTitleFile = onImportTitleFile,
|
||||
onExportTitleFile = onExportTitleFile,
|
||||
retrieveTitleIcon = retrieveTitleIcon,
|
||||
)
|
||||
}
|
||||
@ -223,18 +232,22 @@ private fun DSiWareTitleList(
|
||||
modifier: Modifier,
|
||||
titles: List<DSiWareTitle>,
|
||||
onDeleteTitle: (DSiWareTitle) -> Unit,
|
||||
onImportTitleFile: (DSiWareTitle, DSiWareTitleFileType) -> Unit,
|
||||
onExportTitleFile: (DSiWareTitle, DSiWareTitleFileType) -> Unit,
|
||||
retrieveTitleIcon: (DSiWareTitle) -> RomIcon,
|
||||
) {
|
||||
LazyColumn(modifier) {
|
||||
items(
|
||||
items = titles,
|
||||
key = { it.titleId },
|
||||
) {
|
||||
) { dSiWareTitle ->
|
||||
DSiWareItem(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
item = it,
|
||||
onDeleteClicked = { onDeleteTitle(it) },
|
||||
retrieveTitleIcon = { retrieveTitleIcon(it) },
|
||||
item = dSiWareTitle,
|
||||
onDeleteClicked = { onDeleteTitle(dSiWareTitle) },
|
||||
onImportFile = { onImportTitleFile(dSiWareTitle, it) },
|
||||
onExportFile = { onExportTitleFile(dSiWareTitle, it) },
|
||||
retrieveTitleIcon = { retrieveTitleIcon(dSiWareTitle) },
|
||||
)
|
||||
}
|
||||
}
|
||||
@ -250,13 +263,15 @@ private fun PreviewDSiWareManagerReady() {
|
||||
Ready(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
titles = listOf(
|
||||
DSiWareTitle("Legit Game", "Notendo", 0, ByteArray(0)),
|
||||
DSiWareTitle("Legit Game: Snapped!", "Upasuft", 1, ByteArray(0)),
|
||||
DSiWareTitle("Highway 4 - Mediocre Racing", "Microware", 2, ByteArray(0)),
|
||||
DSiWareTitle("Legit Game", "Notendo", 0, ByteArray(0), 0, 0, 0),
|
||||
DSiWareTitle("Legit Game: Snapped!", "Upasuft", 1, ByteArray(0), 0, 0, 0),
|
||||
DSiWareTitle("Highway 4 - Mediocre Racing", "Microware", 2, ByteArray(0), 0, 0, 0),
|
||||
),
|
||||
onImportTitleFromFile = {},
|
||||
onImportTitleFromRomList = {},
|
||||
onDeleteTitle = {},
|
||||
onImportTitleFile = { _, _ -> },
|
||||
onExportTitleFile = { _, _ -> },
|
||||
retrieveTitleIcon = { RomIcon(bitmap, RomIconFiltering.NONE) },
|
||||
)
|
||||
}
|
||||
|
@ -0,0 +1,117 @@
|
||||
package me.magnum.melonds.ui.dsiwaremanager.ui
|
||||
|
||||
import android.net.Uri
|
||||
import androidx.activity.compose.ManagedActivityResultLauncher
|
||||
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberUpdatedState
|
||||
import me.magnum.melonds.common.Permission
|
||||
import me.magnum.melonds.common.contracts.CreateFileContract
|
||||
import me.magnum.melonds.common.contracts.FilePickerContract
|
||||
import me.magnum.melonds.domain.model.DSiWareTitle
|
||||
import me.magnum.melonds.domain.model.dsinand.DSiWareTitleFileType
|
||||
|
||||
@Composable
|
||||
fun rememberDSiWareTitleImportFilePicker(
|
||||
onFilePicked: (title: DSiWareTitle, fileType: DSiWareTitleFileType, fileUri: Uri) -> Unit,
|
||||
): DSiWareTitleFilePickerLauncher {
|
||||
return rememberDSiWareTitleFilePicker(onFilePicked = onFilePicked, permission = Permission.READ)
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun rememberDSiWareTitleExportFilePicker(
|
||||
onFilePicked: (title: DSiWareTitle, fileType: DSiWareTitleFileType, fileUri: Uri) -> Unit,
|
||||
): DSiWareTitleNewFilePickerLauncher {
|
||||
return rememberDSiWareTitleNewFilePicker(onFilePicked)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun rememberDSiWareTitleFilePicker(
|
||||
onFilePicked: (title: DSiWareTitle, fileType: DSiWareTitleFileType, fileUri: Uri) -> Unit,
|
||||
permission: Permission,
|
||||
): DSiWareTitleFilePickerLauncher {
|
||||
val onFilePickedCallback = rememberUpdatedState(onFilePicked)
|
||||
val requestData = remember {
|
||||
DSiWareTitleFilePickerRequestData()
|
||||
}
|
||||
val filePickerLauncher = rememberLauncherForActivityResult(
|
||||
contract = FilePickerContract(permission),
|
||||
onResult = {
|
||||
if (it != null) {
|
||||
val title = requestData.currentTitle ?: return@rememberLauncherForActivityResult
|
||||
val fileType = requestData.currentFileType ?: return@rememberLauncherForActivityResult
|
||||
|
||||
onFilePickedCallback.value(title, fileType, it)
|
||||
}
|
||||
requestData.currentTitle = null
|
||||
requestData.currentFileType = null
|
||||
},
|
||||
)
|
||||
|
||||
return remember {
|
||||
DSiWareTitleFilePickerLauncher(
|
||||
requestData,
|
||||
filePickerLauncher,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun rememberDSiWareTitleNewFilePicker(
|
||||
onFilePicked: (title: DSiWareTitle, fileType: DSiWareTitleFileType, fileUri: Uri) -> Unit,
|
||||
): DSiWareTitleNewFilePickerLauncher {
|
||||
val onFilePickedCallback = rememberUpdatedState(onFilePicked)
|
||||
val requestData = remember {
|
||||
DSiWareTitleFilePickerRequestData()
|
||||
}
|
||||
val filePickerLauncher = rememberLauncherForActivityResult(
|
||||
contract = CreateFileContract(),
|
||||
onResult = {
|
||||
if (it != null) {
|
||||
val title = requestData.currentTitle ?: return@rememberLauncherForActivityResult
|
||||
val fileType = requestData.currentFileType ?: return@rememberLauncherForActivityResult
|
||||
|
||||
onFilePickedCallback.value(title, fileType, it)
|
||||
}
|
||||
requestData.currentTitle = null
|
||||
requestData.currentFileType = null
|
||||
},
|
||||
)
|
||||
|
||||
return remember {
|
||||
DSiWareTitleNewFilePickerLauncher(
|
||||
requestData = requestData,
|
||||
filePickerLauncher = filePickerLauncher,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
internal class DSiWareTitleFilePickerRequestData {
|
||||
var currentTitle: DSiWareTitle? = null
|
||||
var currentFileType: DSiWareTitleFileType? = null
|
||||
}
|
||||
|
||||
class DSiWareTitleFilePickerLauncher internal constructor(
|
||||
private val requestData: DSiWareTitleFilePickerRequestData,
|
||||
private val filePickerLauncher: ManagedActivityResultLauncher<Pair<Uri?, Array<String>?>, Uri?>,
|
||||
) {
|
||||
|
||||
fun launch(title: DSiWareTitle, fileType: DSiWareTitleFileType) {
|
||||
requestData.currentTitle = title
|
||||
requestData.currentFileType = fileType
|
||||
filePickerLauncher.launch(null to null)
|
||||
}
|
||||
}
|
||||
|
||||
class DSiWareTitleNewFilePickerLauncher internal constructor(
|
||||
private val requestData: DSiWareTitleFilePickerRequestData,
|
||||
private val filePickerLauncher: ManagedActivityResultLauncher<String, Uri?>,
|
||||
) {
|
||||
|
||||
fun launch(title: DSiWareTitle, fileType: DSiWareTitleFileType) {
|
||||
requestData.currentTitle = title
|
||||
requestData.currentFileType = fileType
|
||||
filePickerLauncher.launch(fileType.fileName)
|
||||
}
|
||||
}
|
10
app/src/main/res/drawable/ic_menu.xml
Normal file
10
app/src/main/res/drawable/ic_menu.xml
Normal file
@ -0,0 +1,10 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:viewportWidth="24"
|
||||
android:viewportHeight="24"
|
||||
android:tint="@android:color/white">
|
||||
<path
|
||||
android:fillColor="@android:color/white"
|
||||
android:pathData="M12,8c1.1,0 2,-0.9 2,-2s-0.9,-2 -2,-2 -2,0.9 -2,2 0.9,2 2,2zM12,10c-1.1,0 -2,0.9 -2,2s0.9,2 2,2 2,-0.9 2,-2 -0.9,-2 -2,-2zM12,16c-1.1,0 -2,0.9 -2,2s0.9,2 2,2 2,-0.9 2,-2 -0.9,-2 -2,-2z"/>
|
||||
</vector>
|
@ -74,6 +74,12 @@
|
||||
<string name="dsiware_manager_import_title_error_insatll_failed">Failed to install title</string>
|
||||
<string name="dsiware_manager_import_title_error_metadat_fetch_failed">Failed to download title metadata. Check your internet connection</string>
|
||||
<string name="dsiware_manager_import_title_error_unknown">An unknown error occurred</string>
|
||||
<string name="dsiware_manager_import_data">Import data…</string>
|
||||
<string name="dsiware_manager_export_data">Export data…</string>
|
||||
<string name="dsiware_manager_import_file_success">%1$s imported successfully</string>
|
||||
<string name="dsiware_manager_import_file_error">Failed to import file</string>
|
||||
<string name="dsiware_manager_export_file_success">%1$s exported successfully</string>
|
||||
<string name="dsiware_manager_export_file_error">Failed to export file</string>
|
||||
<string name="dsiware_import_from_file">From file</string>
|
||||
<string name="dsiware_import_from_rom_list">From ROM list</string>
|
||||
|
||||
|
@ -1 +1 @@
|
||||
Subproject commit 2de14f7cb52a09e6eefa373435768ca069c1fdf5
|
||||
Subproject commit 3960f4699bd1030ba129ab9c79cd6235a7a1ddb1
|
Loading…
Reference in New Issue
Block a user