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