Allow DSiWare data files to be imported and exported

This commit is contained in:
Rafael Caetano 2024-01-23 22:58:37 +00:00
parent 32b1721a51
commit 337405914e
19 changed files with 491 additions and 50 deletions

View File

@ -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;
}

View File

@ -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()
}

View File

@ -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
}
}
}

View File

@ -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

View File

@ -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

View File

@ -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
}

View File

@ -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"),
}

View File

@ -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()
}

View File

@ -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

View File

@ -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)
}
}
}

View File

@ -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))

View File

@ -0,0 +1,8 @@
package me.magnum.melonds.ui.dsiwaremanager.model
enum class DSiWareItemDropdownMenu {
NONE,
MAIN,
IMPORT,
EXPORT,
}

View File

@ -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()
}

View File

@ -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) },
)
}
}

View File

@ -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) },
)
}

View File

@ -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)
}
}

View 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>

View File

@ -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