diff --git a/.kotlin/sessions/kotlin-compiler-9781780346823298664.salive b/.kotlin/sessions/kotlin-compiler-9781780346823298664.salive new file mode 100644 index 0000000000..e69de29bb2 diff --git a/fix.py b/fix.py new file mode 100644 index 0000000000..efda6c8e4d --- /dev/null +++ b/fix.py @@ -0,0 +1,26 @@ +import sys + +filename = r'c:\Users\irem\OneDrive\Belgeler\Projects\VibeCode\Lemuroid\lemuroid-app\src\main\java\com\swordfish\lemuroid\app\mobile\feature\settings\general\SettingsScreen.kt' + +with open(filename, 'r', encoding='utf-8') as f: + content = f.read() + +old_str = ''' LocalSaveSyncSettings( + state = state, + onChangeFolder = { viewModel.changeLocalSaveSyncFolder() }, + onSyncNow = { viewModel.syncLocalSaveSyncFolderManually() } + )''' + +new_str = ''' val viewContext = androidx.compose.ui.platform.LocalContext.current + LocalSaveSyncSettings( + state = state, + onChangeFolder = { viewModel.changeLocalSaveSyncFolder(viewContext) }, + onSyncNow = { viewModel.syncLocalSaveSyncFolderManually(viewContext) } + )''' + +content = content.replace(old_str, new_str) + +with open(filename, 'w', encoding='utf-8', newline='\n') as f: + f.write(content) + +print("Done") diff --git a/lemuroid-app/src/main/AndroidManifest.xml b/lemuroid-app/src/main/AndroidManifest.xml index ed9b150c75..ff86c9fd91 100644 --- a/lemuroid-app/src/main/AndroidManifest.xml +++ b/lemuroid-app/src/main/AndroidManifest.xml @@ -95,6 +95,10 @@ android:name="com.swordfish.lemuroid.app.shared.settings.StorageFrameworkPickerLauncher" android:theme="@style/LemuroidMaterialTheme.Invisible" /> + + Unit, + onSyncNow: () -> Unit, +) { + val context = androidx.compose.ui.platform.LocalContext.current + + val currentDirectory = state.localSaveDirectory ?: "" + val emptyDirectory = androidx.compose.ui.res.stringResource(com.swordfish.lemuroid.R.string.none) + + val currentDirectoryName = + androidx.compose.runtime.remember(state.localSaveDirectory) { + runCatching { + if (currentDirectory.isNotEmpty()) { + androidx.documentfile.provider.DocumentFile.fromTreeUri(context, android.net.Uri.parse(currentDirectory))?.name + } else null + }.getOrNull() ?: emptyDirectory + } + + com.swordfish.lemuroid.app.utils.android.settings.LemuroidCardSettingsGroup(title = { androidx.compose.material3.Text(text = androidx.compose.ui.res.stringResource(id = com.swordfish.lemuroid.R.string.settings_title_local_save_sync)) }) { + com.swordfish.lemuroid.app.utils.android.settings.LemuroidSettingsMenuLink( + title = { androidx.compose.material3.Text(text = androidx.compose.ui.res.stringResource(id = com.swordfish.lemuroid.R.string.directory)) }, + subtitle = { androidx.compose.material3.Text(text = currentDirectoryName) }, + onClick = { onChangeFolder() } + ) + val extensionValues = androidx.compose.ui.res.stringArrayResource(id = com.swordfish.lemuroid.R.array.pref_key_local_save_sync_export_extension_values).toList() + val extensionDisplayNames = androidx.compose.ui.res.stringArrayResource(id = com.swordfish.lemuroid.R.array.pref_key_local_save_sync_export_extension_display_names).toList() + + val extensionState = com.swordfish.lemuroid.app.utils.android.settings.indexPreferenceState( + id = com.swordfish.lemuroid.R.string.pref_key_local_save_sync_export_extension, + default = "default", + values = extensionValues + ) + + com.swordfish.lemuroid.app.utils.android.settings.LemuroidSettingsList( + state = extensionState, + title = { androidx.compose.material3.Text(text = androidx.compose.ui.res.stringResource(id = com.swordfish.lemuroid.R.string.settings_title_local_save_sync_export_extension)) }, + items = extensionDisplayNames, + enabled = currentDirectory.isNotEmpty() + ) + + com.swordfish.lemuroid.app.utils.android.settings.LemuroidSettingsMenuLink( + title = { androidx.compose.material3.Text(text = androidx.compose.ui.res.stringResource(id = com.swordfish.lemuroid.R.string.local_save_sync_sync_now)) }, + subtitle = { androidx.compose.material3.Text(text = androidx.compose.ui.res.stringResource(id = com.swordfish.lemuroid.R.string.local_save_sync_sync_now_description)) }, + onClick = { onSyncNow() }, + enabled = currentDirectory.isNotEmpty() + ) + } +} diff --git a/lemuroid-app/src/main/java/com/swordfish/lemuroid/app/mobile/feature/settings/general/SettingsViewModel.kt b/lemuroid-app/src/main/java/com/swordfish/lemuroid/app/mobile/feature/settings/general/SettingsViewModel.kt index ee476b056f..2a21df1ad3 100644 --- a/lemuroid-app/src/main/java/com/swordfish/lemuroid/app/mobile/feature/settings/general/SettingsViewModel.kt +++ b/lemuroid-app/src/main/java/com/swordfish/lemuroid/app/mobile/feature/settings/general/SettingsViewModel.kt @@ -40,6 +40,7 @@ class SettingsViewModel( data class State( val currentDirectory: String = "", val isSaveSyncSupported: Boolean = false, + val localSaveDirectory: String = "", ) val indexingInProgress = PendingOperationsMonitor(context).anyLibraryOperationInProgress() @@ -47,13 +48,24 @@ class SettingsViewModel( val directoryScanInProgress = PendingOperationsMonitor(context).isDirectoryScanInProgress() val uiState = - sharedPreferences.getString(context.getString(com.swordfish.lemuroid.lib.R.string.pref_key_extenral_folder)) - .asFlow() + kotlinx.coroutines.flow.combine( + sharedPreferences.getString(context.getString(com.swordfish.lemuroid.lib.R.string.pref_key_extenral_folder)).asFlow(), + sharedPreferences.getString(context.getString(com.swordfish.lemuroid.lib.R.string.pref_key_local_save_sync_folder)).asFlow() + ) { romsDir, localSavesDir -> + State(romsDir, saveSyncManager.isSupported(), localSavesDir) + } .flowOn(Dispatchers.IO) - .stateIn(viewModelScope, SharingStarted.Lazily, "") - .map { State(it, saveSyncManager.isSupported()) } + .stateIn(viewModelScope, SharingStarted.Lazily, State()) fun changeLocalStorageFolder() { settingsInteractor.changeLocalStorageFolder() } + + fun changeLocalSaveSyncFolder(context: Context) { + com.swordfish.lemuroid.app.shared.settings.LocalSaveSyncPickerLauncher.pickFolder(context) + } + + fun syncLocalSaveSyncFolderManually(context: Context) { + com.swordfish.lemuroid.app.shared.savesync.LocalSaveSyncWork.enqueueManualWork(context) + } } diff --git a/lemuroid-app/src/main/java/com/swordfish/lemuroid/app/shared/game/GameLauncher.kt b/lemuroid-app/src/main/java/com/swordfish/lemuroid/app/shared/game/GameLauncher.kt index 413e9d7ffc..235cb4d56d 100644 --- a/lemuroid-app/src/main/java/com/swordfish/lemuroid/app/shared/game/GameLauncher.kt +++ b/lemuroid-app/src/main/java/com/swordfish/lemuroid/app/shared/game/GameLauncher.kt @@ -30,6 +30,11 @@ class GameLauncher( GlobalScope.launch { val system = GameSystem.findById(game.systemId) val coreConfig = coresSelection.getCoreConfigForSystem(system) + + // Perform an immediate bidirectional local sync right before the Core launches. + // This ensures any newly placed manually copied .sav files are injected into Lemuroid! + com.swordfish.lemuroid.app.shared.savesync.LocalSaveSyncWork.performSync(activity.applicationContext) + gameLaunchTaskHandler.handleGameStart(activity.applicationContext) BaseGameActivity.launchGame(activity, coreConfig, game, loadSave, leanback) } diff --git a/lemuroid-app/src/main/java/com/swordfish/lemuroid/app/shared/main/GameLaunchTaskHandler.kt b/lemuroid-app/src/main/java/com/swordfish/lemuroid/app/shared/main/GameLaunchTaskHandler.kt index 979052bc52..1f03b4f053 100644 --- a/lemuroid-app/src/main/java/com/swordfish/lemuroid/app/shared/main/GameLaunchTaskHandler.kt +++ b/lemuroid-app/src/main/java/com/swordfish/lemuroid/app/shared/main/GameLaunchTaskHandler.kt @@ -48,12 +48,16 @@ class GameLaunchTaskHandler( private fun cancelBackgroundWork(context: Context) { SaveSyncWork.cancelAutoWork(context) SaveSyncWork.cancelManualWork(context) + // Also cancel local syncs + androidx.work.WorkManager.getInstance(context).cancelUniqueWork(com.swordfish.lemuroid.app.shared.savesync.LocalSaveSyncWork.UNIQUE_PERIODIC_WORK_ID) + androidx.work.WorkManager.getInstance(context).cancelUniqueWork(com.swordfish.lemuroid.app.shared.savesync.LocalSaveSyncWork.UNIQUE_WORK_ID) CacheCleanerWork.cancelCleanCacheLRU(context) } private fun rescheduleBackgroundWork(context: Context) { // Let's slightly delay the sync. Maybe the user wants to play another game. SaveSyncWork.enqueueAutoWork(context, 5) + com.swordfish.lemuroid.app.shared.savesync.LocalSaveSyncWork.enqueueAutoWork(context) CacheCleanerWork.enqueueCleanCacheLRU(context) } @@ -76,6 +80,10 @@ class GameLaunchTaskHandler( val game = data?.extras?.getSerializable(BaseGameActivity.PLAY_GAME_RESULT_GAME) as Game updateGamePlayedTimestamp(game) + + // Push internal saves instantaneously backwards to the user's external SAF directory + com.swordfish.lemuroid.app.shared.savesync.LocalSaveSyncWork.performSync(activity.applicationContext) + if (enableRatingFlow) { displayReviewRequest(activity, duration) } diff --git a/lemuroid-app/src/main/java/com/swordfish/lemuroid/app/shared/savesync/LocalSaveSyncWork.kt b/lemuroid-app/src/main/java/com/swordfish/lemuroid/app/shared/savesync/LocalSaveSyncWork.kt new file mode 100644 index 0000000000..5b3d561730 --- /dev/null +++ b/lemuroid-app/src/main/java/com/swordfish/lemuroid/app/shared/savesync/LocalSaveSyncWork.kt @@ -0,0 +1,266 @@ +package com.swordfish.lemuroid.app.shared.savesync + +import android.content.Context +import android.net.Uri +import androidx.documentfile.provider.DocumentFile +import androidx.work.Constraints +import androidx.work.CoroutineWorker +import androidx.work.ExistingPeriodicWorkPolicy +import androidx.work.ExistingWorkPolicy +import androidx.work.ListenableWorker +import androidx.work.NetworkType +import androidx.work.OneTimeWorkRequestBuilder +import androidx.work.PeriodicWorkRequestBuilder +import androidx.work.WorkManager +import androidx.work.WorkerParameters +import com.swordfish.lemuroid.app.mobile.shared.NotificationsManager +import com.swordfish.lemuroid.app.utils.android.createSyncForegroundInfo +import com.swordfish.lemuroid.lib.injection.AndroidWorkerInjection +import com.swordfish.lemuroid.lib.injection.WorkerKey +import com.swordfish.lemuroid.lib.preferences.SharedPreferencesHelper +import com.swordfish.lemuroid.lib.storage.DirectoriesManager +import dagger.Binds +import dagger.android.AndroidInjector +import dagger.multibindings.IntoMap +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import timber.log.Timber +import java.io.File +import java.io.FileInputStream +import java.util.concurrent.TimeUnit +import javax.inject.Inject + +class LocalSaveSyncWork(context: Context, workerParams: WorkerParameters) : + CoroutineWorker(context, workerParams) { + + @Inject + lateinit var directoriesManager: DirectoriesManager + + override suspend fun doWork(): Result { + AndroidWorkerInjection.inject(this) + performSync(applicationContext) + displayNotification() + return Result.success() + } + + private fun displayNotification() { + val notificationsManager = NotificationsManager(applicationContext) + val foregroundInfo = createSyncForegroundInfo( + NotificationsManager.SAVE_SYNC_NOTIFICATION_ID, + notificationsManager.saveSyncNotification(), + ) + setForegroundAsync(foregroundInfo) + } + + companion object { + val UNIQUE_WORK_ID: String = LocalSaveSyncWork::class.java.simpleName + val UNIQUE_PERIODIC_WORK_ID: String = LocalSaveSyncWork::class.java.simpleName + "Periodic" + + suspend fun performSync(applicationContext: Context) { + val legacyPreferences = SharedPreferencesHelper.getLegacySharedPreferences(applicationContext) + val prefKey = applicationContext.getString(com.swordfish.lemuroid.lib.R.string.pref_key_local_save_sync_folder) + val uriString = legacyPreferences.getString(prefKey, null) ?: return + + val harmonyPreferences = SharedPreferencesHelper.getSharedPreferences(applicationContext) + val exportExtKey = applicationContext.getString(com.swordfish.lemuroid.R.string.pref_key_local_save_sync_export_extension) + val exportExtValue = harmonyPreferences.getString(exportExtKey, "default") ?: "default" + + if (uriString.isEmpty()) return + + val destDirUri = Uri.parse(uriString) + val destDir = DocumentFile.fromTreeUri(applicationContext, destDirUri) ?: return + + val directoriesManager = DirectoriesManager(applicationContext) + try { + withContext(Dispatchers.IO) { + val savesDir = directoriesManager.getSavesDirectory() + if (savesDir.exists()) { + val savesDest = getOrCreateDirectory(destDir, "saves") + if (savesDest != null) { + syncFiles(savesDir, savesDest, exportExtValue, applicationContext) + } + } + + val statesDir = directoriesManager.getStatesDirectory() + if (statesDir.exists()) { + val statesDest = getOrCreateDirectory(destDir, "states") + if (statesDest != null) { + syncFiles(statesDir, statesDest, exportExtValue, applicationContext) + } + } + } + } catch (e: Exception) { + Timber.e(e, "Error during local save sync") + } + } + + private fun getOrCreateDirectory(parent: DocumentFile, name: String): DocumentFile? { + val existing = parent.findFile(name) + if (existing != null && existing.isDirectory) { + return existing + } + return parent.createDirectory(name) + } + + private fun syncFiles(sourceDir: File, destDir: DocumentFile, exportExtValue: String, applicationContext: Context) { + val syncStateFile = File(sourceDir, ".lemuroid_sync_state") + val syncMap = mutableMapOf() + if (syncStateFile.exists()) { + try { + val lines = syncStateFile.readLines() + for (line in lines) { + val parts = line.split("|") + if (parts.size == 2) { + syncMap[parts[0]] = parts[1].toLong() + } + } + } catch (e: Exception) { + Timber.e(e, "Failed to read sync state file") + } + } + + val intFiles = sourceDir.listFiles()?.filter { !it.name.startsWith(".lemuroid_sync_state") } ?: emptyList() + val extFiles = destDir.listFiles() + + // 1. Process Internal Files + for (intFile in intFiles) { + if (intFile.isDirectory) { + val subDestDir = getOrCreateDirectory(destDir, intFile.name) + if (subDestDir != null) { + syncFiles(intFile, subDestDir, exportExtValue, applicationContext) + } + continue + } + + val intName = intFile.name + var extName = intName + if (exportExtValue == "sav" && (extName.endsWith(".srm") || extName.endsWith(".dsv"))) { + extName = extName.substringBeforeLast(".") + ".sav" + } + + val extFile = destDir.findFile(extName) + + val lastIntSync = syncMap["INT_$intName"] ?: 0L + val lastExtSync = syncMap["EXT_$extName"] ?: 0L + + val intChanged = Math.abs(intFile.lastModified() - lastIntSync) > 2000L + val extChanged = extFile != null && Math.abs(extFile.lastModified() - lastExtSync) > 2000L + + val shouldSyncIntToExt = if (extFile == null) { + true // External doesn't exist, we must push it + } else if (intChanged && !extChanged) { + true // internal only changed + } else if (intChanged && extChanged) { + intFile.lastModified() > extFile.lastModified() // Conflict! newer wins + } else { + false // neither changed, or only external changed (handled in phase 2) + } + + if (shouldSyncIntToExt) { + val destFile = extFile ?: destDir.createFile("application/octet-stream", extName) + destFile?.let { dFile -> + applicationContext.contentResolver.openOutputStream(dFile.uri)?.use { outStream -> + FileInputStream(intFile).use { inStream -> + inStream.copyTo(outStream) + } + } + syncMap["INT_$intName"] = intFile.lastModified() + syncMap["EXT_$extName"] = dFile.lastModified() + } + } + } + + // 2. Process remaining External Files that were changed/dropped + for (extFile in extFiles) { + if (extFile.isDirectory) continue + val extName = extFile.name ?: continue + + var intName = extName + if (exportExtValue == "sav" && extName.endsWith(".sav")) { + val baseName = extName.substringBeforeLast(".") + intName = if (File(sourceDir, "$baseName.dsv").exists()) { + "$baseName.dsv" + } else { + "$baseName.srm" + } + } + + val intFile = File(sourceDir, intName) + + val lastIntSync = syncMap["INT_$intName"] ?: 0L + val lastExtSync = syncMap["EXT_$extName"] ?: 0L + + val intChanged = intFile.exists() && Math.abs(intFile.lastModified() - lastIntSync) > 2000L + val extChanged = Math.abs(extFile.lastModified() - lastExtSync) > 2000L + + val shouldSyncExtToInt = if (!intFile.exists()) { + true // Internal missing, pull it! + } else if (extChanged && !intChanged) { + true // External only changed (like user dropping an old file) + } else if (extChanged && intChanged) { + extFile.lastModified() > intFile.lastModified() // conflict + } else { + false + } + + if (shouldSyncExtToInt) { + applicationContext.contentResolver.openInputStream(extFile.uri)?.use { inStream -> + java.io.FileOutputStream(intFile).use { outStream -> + inStream.copyTo(outStream) + } + } + syncMap["INT_$intName"] = intFile.lastModified() + syncMap["EXT_$extName"] = extFile.lastModified() + } + } + + // Save state + try { + syncStateFile.bufferedWriter().use { out -> + for ((k, v) in syncMap) { + out.write("$k|$v\n") + } + } + } catch (e: Exception) { + Timber.e(e, "Failed to write sync state file") + } + } + + fun enqueueManualWork(applicationContext: Context) { + WorkManager.getInstance(applicationContext).enqueueUniqueWork( + UNIQUE_WORK_ID, + ExistingWorkPolicy.REPLACE, + OneTimeWorkRequestBuilder() + .build(), + ) + } + + fun enqueueAutoWork(applicationContext: Context) { + WorkManager.getInstance(applicationContext).enqueueUniquePeriodicWork( + UNIQUE_PERIODIC_WORK_ID, + ExistingPeriodicWorkPolicy.REPLACE, + PeriodicWorkRequestBuilder(4, TimeUnit.HOURS) + .setConstraints( + Constraints.Builder() + .setRequiresBatteryNotLow(true) + .build(), + ) + .build(), + ) + } + } + + @dagger.Module(subcomponents = [Subcomponent::class]) + abstract class Module { + @Binds + @IntoMap + @WorkerKey(LocalSaveSyncWork::class) + abstract fun bindMyWorkerFactory(builder: Subcomponent.Builder): AndroidInjector.Factory + } + + @dagger.Subcomponent + interface Subcomponent : AndroidInjector { + @dagger.Subcomponent.Builder + abstract class Builder : AndroidInjector.Builder() + } +} diff --git a/lemuroid-app/src/main/java/com/swordfish/lemuroid/app/shared/settings/LocalSaveSyncPickerLauncher.kt b/lemuroid-app/src/main/java/com/swordfish/lemuroid/app/shared/settings/LocalSaveSyncPickerLauncher.kt new file mode 100644 index 0000000000..0bc69963ad --- /dev/null +++ b/lemuroid-app/src/main/java/com/swordfish/lemuroid/app/shared/settings/LocalSaveSyncPickerLauncher.kt @@ -0,0 +1,87 @@ +package com.swordfish.lemuroid.app.shared.settings + +import android.app.Activity +import android.content.Context +import android.content.Intent +import android.net.Uri +import android.os.Bundle +import com.swordfish.lemuroid.R +import com.swordfish.lemuroid.app.utils.android.displayErrorDialog +import com.swordfish.lemuroid.lib.android.RetrogradeActivity +import com.swordfish.lemuroid.lib.preferences.SharedPreferencesHelper +import com.swordfish.lemuroid.lib.storage.DirectoriesManager +import javax.inject.Inject + +class LocalSaveSyncPickerLauncher : RetrogradeActivity() { + @Inject + lateinit var directoriesManager: DirectoriesManager + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + + if (savedInstanceState == null) { + val intent = + Intent(Intent.ACTION_OPEN_DOCUMENT_TREE).apply { + this.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) + this.addFlags(Intent.FLAG_GRANT_WRITE_URI_PERMISSION) + this.addFlags(Intent.FLAG_GRANT_PERSISTABLE_URI_PERMISSION) + this.addFlags(Intent.FLAG_GRANT_PREFIX_URI_PERMISSION) + this.putExtra(Intent.EXTRA_LOCAL_ONLY, true) + } + try { + startActivityForResult(intent, REQUEST_CODE_PICK_FOLDER) + } catch (e: Exception) { + showStorageAccessFrameworkNotSupportedDialog() + } + } + } + + private fun showStorageAccessFrameworkNotSupportedDialog() { + val message = getString(R.string.dialog_saf_not_found, directoriesManager.getSavesDirectory()) + val actionLabel = getString(R.string.ok) + displayErrorDialog(message, actionLabel) { finish() } + } + + override fun onActivityResult( + requestCode: Int, + resultCode: Int, + resultData: Intent?, + ) { + super.onActivityResult(requestCode, resultCode, resultData) + + if (requestCode == REQUEST_CODE_PICK_FOLDER && resultCode == Activity.RESULT_OK) { + val sharedPreferences = SharedPreferencesHelper.getLegacySharedPreferences(this) + val preferenceKey = getString(com.swordfish.lemuroid.lib.R.string.pref_key_local_save_sync_folder) + + val currentValue: String? = sharedPreferences.getString(preferenceKey, null) + val newValue = resultData?.data + + if (newValue != null && newValue.toString() != currentValue) { + updatePersistableUris(newValue) + + sharedPreferences.edit().apply { + this.putString(preferenceKey, newValue.toString()) + this.apply() + } + } + } + finish() + } + + private fun updatePersistableUris(uri: Uri) { + // Release older permissions for local save sync if any exist, to avoid leaking. + // For simplicity, we just grant the new one. + contentResolver.takePersistableUriPermission( + uri, + Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_GRANT_WRITE_URI_PERMISSION + ) + } + + companion object { + private const val REQUEST_CODE_PICK_FOLDER = 101 + + fun pickFolder(context: Context) { + context.startActivity(Intent(context, LocalSaveSyncPickerLauncher::class.java)) + } + } +} diff --git a/lemuroid-app/src/main/java/com/swordfish/lemuroid/app/shared/startup/MainProcessInitializer.kt b/lemuroid-app/src/main/java/com/swordfish/lemuroid/app/shared/startup/MainProcessInitializer.kt index 9c3768726e..5c3747c347 100644 --- a/lemuroid-app/src/main/java/com/swordfish/lemuroid/app/shared/startup/MainProcessInitializer.kt +++ b/lemuroid-app/src/main/java/com/swordfish/lemuroid/app/shared/startup/MainProcessInitializer.kt @@ -11,6 +11,7 @@ class MainProcessInitializer : Initializer { override fun create(context: Context) { Timber.i("Requested initialization of main process tasks") SaveSyncWork.enqueueAutoWork(context, 0) + com.swordfish.lemuroid.app.shared.savesync.LocalSaveSyncWork.enqueueAutoWork(context) LibraryIndexScheduler.scheduleCoreUpdate(context) } diff --git a/lemuroid-app/src/main/res/values/keys.xml b/lemuroid-app/src/main/res/values/keys.xml index 99c3c0d15c..07558bf57b 100644 --- a/lemuroid-app/src/main/res/values/keys.xml +++ b/lemuroid-app/src/main/res/values/keys.xml @@ -26,6 +26,18 @@ save_sync_configure save_sync_cores + local_save_sync_export_extension + + + default + sav + + + + @string/export_extension_default + @string/export_extension_sav + + shader_filter_0 hd_mode diff --git a/lemuroid-app/src/main/res/values/strings.xml b/lemuroid-app/src/main/res/values/strings.xml index db1ee0bd14..cdb903ddb9 100644 --- a/lemuroid-app/src/main/res/values/strings.xml +++ b/lemuroid-app/src/main/res/values/strings.xml @@ -234,4 +234,13 @@ L1 - R1 L2 - R2 + Local Save Directory + Select custom folder to backup saves and states + Sync Now + Copy saves and states to external directory + + Export Saves As + Original (.srm, .dsv) + Raw Save (.sav) +