Skip to content

Commit

Permalink
Allow DSiWare data files to be imported and exported
Browse files Browse the repository at this point in the history
rafaelvcaetano committed Jan 23, 2024

Verified

This commit was created on GitHub.com and signed with GitHub’s verified signature.
1 parent 32b1721 commit 3374059
Showing 19 changed files with 491 additions and 50 deletions.
54 changes: 49 additions & 5 deletions app/src/main/cpp/MelonDSNandJNI.cpp
Original file line number Diff line number Diff line change
@@ -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;
}
2 changes: 2 additions & 0 deletions app/src/main/java/me/magnum/melonds/MelonDSiNand.kt
Original file line number Diff line number Diff line change
@@ -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()
}
Original file line number Diff line number Diff line change
@@ -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
}
}
}
Original file line number Diff line number Diff line change
@@ -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
Original file line number Diff line number Diff line change
@@ -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
12 changes: 11 additions & 1 deletion app/src/main/java/me/magnum/melonds/domain/model/DSiWareTitle.kt
Original file line number Diff line number Diff line change
@@ -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
}
Original file line number Diff line number Diff line change
@@ -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"),
}
Original file line number Diff line number Diff line change
@@ -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()
}
17 changes: 17 additions & 0 deletions app/src/main/java/me/magnum/melonds/impl/AndroidDSiNandManager.kt
Original file line number Diff line number Diff line change
@@ -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
Original file line number Diff line number Diff line change
@@ -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)
}
}
}
Original file line number Diff line number Diff line change
@@ -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))
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
package me.magnum.melonds.ui.dsiwaremanager.model

enum class DSiWareItemDropdownMenu {
NONE,
MAIN,
IMPORT,
EXPORT,
}
Original file line number Diff line number Diff line change
@@ -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()
}
161 changes: 137 additions & 24 deletions app/src/main/java/me/magnum/melonds/ui/dsiwaremanager/ui/DSiWareItem.kt
Original file line number Diff line number Diff line change
@@ -2,24 +2,27 @@ 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
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) },
)
}
}
Original file line number Diff line number Diff line change
@@ -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) },
)
}
Original file line number Diff line number Diff line change
@@ -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 changes: 10 additions & 0 deletions app/src/main/res/drawable/ic_menu.xml
Original file line number Diff line number Diff line change
@@ -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>
6 changes: 6 additions & 0 deletions app/src/main/res/values/strings.xml
Original file line number Diff line number Diff line change
@@ -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>

2 changes: 1 addition & 1 deletion melonDS-android-lib

0 comments on commit 3374059

Please sign in to comment.