From 337405914e8cb11473ec12090fdb4927a868854e Mon Sep 17 00:00:00 2001 From: Rafael Caetano Date: Tue, 23 Jan 2024 22:58:37 +0000 Subject: [PATCH] Allow DSiWare data files to be imported and exported --- app/src/main/cpp/MelonDSNandJNI.cpp | 54 +++++- .../java/me/magnum/melonds/MelonDSiNand.kt | 2 + .../common/contracts/CreateFileContract.kt | 24 +++ .../contracts/DirectoryPickerContract.kt | 4 - .../common/contracts/FilePickerContract.kt | 5 +- .../melonds/domain/model/DSiWareTitle.kt | 12 +- .../model/dsinand/DSiWareTitleFileType.kt | 7 + .../melonds/domain/services/DSiNandManager.kt | 3 + .../melonds/impl/AndroidDSiNandManager.kt | 17 ++ .../dsiwaremanager/DSiWareManagerActivity.kt | 35 +++- .../dsiwaremanager/DSiWareManagerViewModel.kt | 37 ++++ .../model/DSiWareItemDropdownMenu.kt | 8 + .../ImportExportDSiWareTitleFileEvent.kt | 8 + .../ui/dsiwaremanager/ui/DSiWareItem.kt | 161 +++++++++++++++--- .../ui/dsiwaremanager/ui/DSiWareManager.kt | 29 +++- .../ui/DSiWareTitleFileActions.kt | 117 +++++++++++++ app/src/main/res/drawable/ic_menu.xml | 10 ++ app/src/main/res/values/strings.xml | 6 + melonDS-android-lib | 2 +- 19 files changed, 491 insertions(+), 50 deletions(-) create mode 100644 app/src/main/java/me/magnum/melonds/common/contracts/CreateFileContract.kt create mode 100644 app/src/main/java/me/magnum/melonds/domain/model/dsinand/DSiWareTitleFileType.kt create mode 100644 app/src/main/java/me/magnum/melonds/ui/dsiwaremanager/model/DSiWareItemDropdownMenu.kt create mode 100644 app/src/main/java/me/magnum/melonds/ui/dsiwaremanager/model/ImportExportDSiWareTitleFileEvent.kt create mode 100644 app/src/main/java/me/magnum/melonds/ui/dsiwaremanager/ui/DSiWareTitleFileActions.kt create mode 100644 app/src/main/res/drawable/ic_menu.xml diff --git a/app/src/main/cpp/MelonDSNandJNI.cpp b/app/src/main/cpp/MelonDSNandJNI.cpp index db13c8de..99186555 100644 --- a/app/src/main/cpp/MelonDSNandJNI.cpp +++ b/app/src/main/cpp/MelonDSNandJNI.cpp @@ -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 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, "", "(Ljava/lang/String;Ljava/lang/String;J[B)V"); + jmethodID dsiWareTitleConstructor = env->GetMethodID(dsiWareTitleClass, "", "(Ljava/lang/String;Ljava/lang/String;J[BJJI)V"); std::wstring_convert, 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; } \ No newline at end of file diff --git a/app/src/main/java/me/magnum/melonds/MelonDSiNand.kt b/app/src/main/java/me/magnum/melonds/MelonDSiNand.kt index ef55f9e5..c102a4da 100644 --- a/app/src/main/java/me/magnum/melonds/MelonDSiNand.kt +++ b/app/src/main/java/me/magnum/melonds/MelonDSiNand.kt @@ -8,5 +8,7 @@ object MelonDSiNand { external fun listTitles(): ArrayList 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() } \ No newline at end of file diff --git a/app/src/main/java/me/magnum/melonds/common/contracts/CreateFileContract.kt b/app/src/main/java/me/magnum/melonds/common/contracts/CreateFileContract.kt new file mode 100644 index 00000000..fad36995 --- /dev/null +++ b/app/src/main/java/me/magnum/melonds/common/contracts/CreateFileContract.kt @@ -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() { + 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 + } + } +} \ No newline at end of file diff --git a/app/src/main/java/me/magnum/melonds/common/contracts/DirectoryPickerContract.kt b/app/src/main/java/me/magnum/melonds/common/contracts/DirectoryPickerContract.kt index 0b987ed8..69ca33eb 100644 --- a/app/src/main/java/me/magnum/melonds/common/contracts/DirectoryPickerContract.kt +++ b/app/src/main/java/me/magnum/melonds/common/contracts/DirectoryPickerContract.kt @@ -31,10 +31,6 @@ class DirectoryPickerContract(private val permissions: Permission) : ActivityRes return intent } - override fun getSynchronousResult(context: Context, input: Uri?): SynchronousResult? { - return null - } - override fun parseResult(resultCode: Int, intent: Intent?): Uri? { return if (intent == null || resultCode != Activity.RESULT_OK) { null diff --git a/app/src/main/java/me/magnum/melonds/common/contracts/FilePickerContract.kt b/app/src/main/java/me/magnum/melonds/common/contracts/FilePickerContract.kt index 46d93936..69087c59 100644 --- a/app/src/main/java/me/magnum/melonds/common/contracts/FilePickerContract.kt +++ b/app/src/main/java/me/magnum/melonds/common/contracts/FilePickerContract.kt @@ -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?>): SynchronousResult? { - return null - } - override fun parseResult(resultCode: Int, intent: Intent?): Uri? { return if (intent == null || resultCode != Activity.RESULT_OK) { null diff --git a/app/src/main/java/me/magnum/melonds/domain/model/DSiWareTitle.kt b/app/src/main/java/me/magnum/melonds/domain/model/DSiWareTitle.kt index e4dbe01e..d8fb8715 100644 --- a/app/src/main/java/me/magnum/melonds/domain/model/DSiWareTitle.kt +++ b/app/src/main/java/me/magnum/melonds/domain/model/DSiWareTitle.kt @@ -5,4 +5,14 @@ class DSiWareTitle( val producer: String, val titleId: Long, val icon: ByteArray, -) \ No newline at end of file + val publicSavSize: Long, + val privateSavSize: Long, + val appFlags: Int, +) { + + fun hasPublicSavFile() = publicSavSize != 0L + + fun hasPrivateSavFile() = privateSavSize != 0L + + fun hasBannerSavFile() = (appFlags and (0x04)) != 0 +} \ No newline at end of file diff --git a/app/src/main/java/me/magnum/melonds/domain/model/dsinand/DSiWareTitleFileType.kt b/app/src/main/java/me/magnum/melonds/domain/model/dsinand/DSiWareTitleFileType.kt new file mode 100644 index 00000000..19d6c221 --- /dev/null +++ b/app/src/main/java/me/magnum/melonds/domain/model/dsinand/DSiWareTitleFileType.kt @@ -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"), +} \ No newline at end of file diff --git a/app/src/main/java/me/magnum/melonds/domain/services/DSiNandManager.kt b/app/src/main/java/me/magnum/melonds/domain/services/DSiNandManager.kt index a81b93b8..a91e6be2 100644 --- a/app/src/main/java/me/magnum/melonds/domain/services/DSiNandManager.kt +++ b/app/src/main/java/me/magnum/melonds/domain/services/DSiNandManager.kt @@ -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 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() } \ No newline at end of file diff --git a/app/src/main/java/me/magnum/melonds/impl/AndroidDSiNandManager.kt b/app/src/main/java/me/magnum/melonds/impl/AndroidDSiNandManager.kt index b3cb5fc3..9210381b 100644 --- a/app/src/main/java/me/magnum/melonds/impl/AndroidDSiNandManager.kt +++ b/app/src/main/java/me/magnum/melonds/impl/AndroidDSiNandManager.kt @@ -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 diff --git a/app/src/main/java/me/magnum/melonds/ui/dsiwaremanager/DSiWareManagerActivity.kt b/app/src/main/java/me/magnum/melonds/ui/dsiwaremanager/DSiWareManagerActivity.kt index 5fe75f8d..d84a1e72 100644 --- a/app/src/main/java/me/magnum/melonds/ui/dsiwaremanager/DSiWareManagerActivity.kt +++ b/app/src/main/java/me/magnum/melonds/ui/dsiwaremanager/DSiWareManagerActivity.kt @@ -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) + } + } } \ No newline at end of file diff --git a/app/src/main/java/me/magnum/melonds/ui/dsiwaremanager/DSiWareManagerViewModel.kt b/app/src/main/java/me/magnum/melonds/ui/dsiwaremanager/DSiWareManagerViewModel.kt index 22745374..3573db14 100644 --- a/app/src/main/java/me/magnum/melonds/ui/dsiwaremanager/DSiWareManagerViewModel.kt +++ b/app/src/main/java/me/magnum/melonds/ui/dsiwaremanager/DSiWareManagerViewModel.kt @@ -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(extraBufferCapacity = 1, onBufferOverflow = BufferOverflow.DROP_OLDEST) val importTitleError: SharedFlow = _importTitleError.asSharedFlow() + private val _importExportFileEvent = MutableSharedFlow(extraBufferCapacity = 1, onBufferOverflow = BufferOverflow.DROP_OLDEST) + val importExportFileEvent: SharedFlow = _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)) diff --git a/app/src/main/java/me/magnum/melonds/ui/dsiwaremanager/model/DSiWareItemDropdownMenu.kt b/app/src/main/java/me/magnum/melonds/ui/dsiwaremanager/model/DSiWareItemDropdownMenu.kt new file mode 100644 index 00000000..96a066de --- /dev/null +++ b/app/src/main/java/me/magnum/melonds/ui/dsiwaremanager/model/DSiWareItemDropdownMenu.kt @@ -0,0 +1,8 @@ +package me.magnum.melonds.ui.dsiwaremanager.model + +enum class DSiWareItemDropdownMenu { + NONE, + MAIN, + IMPORT, + EXPORT, +} \ No newline at end of file diff --git a/app/src/main/java/me/magnum/melonds/ui/dsiwaremanager/model/ImportExportDSiWareTitleFileEvent.kt b/app/src/main/java/me/magnum/melonds/ui/dsiwaremanager/model/ImportExportDSiWareTitleFileEvent.kt new file mode 100644 index 00000000..5c93149c --- /dev/null +++ b/app/src/main/java/me/magnum/melonds/ui/dsiwaremanager/model/ImportExportDSiWareTitleFileEvent.kt @@ -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() +} \ No newline at end of file diff --git a/app/src/main/java/me/magnum/melonds/ui/dsiwaremanager/ui/DSiWareItem.kt b/app/src/main/java/me/magnum/melonds/ui/dsiwaremanager/ui/DSiWareItem.kt index c396dcc4..c5702882 100644 --- a/app/src/main/java/me/magnum/melonds/ui/dsiwaremanager/ui/DSiWareItem.kt +++ b/app/src/main/java/me/magnum/melonds/ui/dsiwaremanager/ui/DSiWareItem.kt @@ -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) }, ) } } \ No newline at end of file diff --git a/app/src/main/java/me/magnum/melonds/ui/dsiwaremanager/ui/DSiWareManager.kt b/app/src/main/java/me/magnum/melonds/ui/dsiwaremanager/ui/DSiWareManager.kt index 054491fb..7317ae46 100644 --- a/app/src/main/java/me/magnum/melonds/ui/dsiwaremanager/ui/DSiWareManager.kt +++ b/app/src/main/java/me/magnum/melonds/ui/dsiwaremanager/ui/DSiWareManager.kt @@ -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, 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) }, ) } diff --git a/app/src/main/java/me/magnum/melonds/ui/dsiwaremanager/ui/DSiWareTitleFileActions.kt b/app/src/main/java/me/magnum/melonds/ui/dsiwaremanager/ui/DSiWareTitleFileActions.kt new file mode 100644 index 00000000..63736693 --- /dev/null +++ b/app/src/main/java/me/magnum/melonds/ui/dsiwaremanager/ui/DSiWareTitleFileActions.kt @@ -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?>, 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, +) { + + fun launch(title: DSiWareTitle, fileType: DSiWareTitleFileType) { + requestData.currentTitle = title + requestData.currentFileType = fileType + filePickerLauncher.launch(fileType.fileName) + } +} \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_menu.xml b/app/src/main/res/drawable/ic_menu.xml new file mode 100644 index 00000000..16a31c56 --- /dev/null +++ b/app/src/main/res/drawable/ic_menu.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 2d22c279..56530f1b 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -74,6 +74,12 @@ Failed to install title Failed to download title metadata. Check your internet connection An unknown error occurred + Import data… + Export data… + %1$s imported successfully + Failed to import file + %1$s exported successfully + Failed to export file From file From ROM list diff --git a/melonDS-android-lib b/melonDS-android-lib index 2de14f7c..3960f469 160000 --- a/melonDS-android-lib +++ b/melonDS-android-lib @@ -1 +1 @@ -Subproject commit 2de14f7cb52a09e6eefa373435768ca069c1fdf5 +Subproject commit 3960f4699bd1030ba129ab9c79cd6235a7a1ddb1