Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Implement Sync via SyncManager #632

Open
wants to merge 23 commits into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from 12 commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
b5c15d1
implement SAF as SaveSyncManagerImpl target
newhinton Apr 9, 2023
419b954
copy non existent files
newhinton Apr 9, 2023
c43379e
copy outdated saves
newhinton Apr 9, 2023
c66d94c
overhaul permissions request
newhinton Apr 9, 2023
388be20
directly use provided folder
newhinton Apr 9, 2023
9089494
create missing internal folder if required
newhinton Apr 9, 2023
3f8e3e4
fix timestamps of copied files to prevent unnessessary copying
newhinton Apr 9, 2023
8cf3c39
update error handling for selecting path
newhinton Apr 10, 2023
2ce2df9
Split checks into functions
newhinton Apr 10, 2023
80a05db
Refactor and document code
newhinton Apr 10, 2023
40aead7
refactor and improve logging
newhinton Apr 10, 2023
614fd67
compute remote space usage
newhinton Apr 10, 2023
0009111
add newline to satisfy lint
newhinton Apr 13, 2023
95ae1ee
migrate dependencies out of build.gradle.kts
newhinton Apr 20, 2023
d8a456f
improve naming
newhinton Apr 20, 2023
01e7a41
allow folders to be synced recursive to SAF
newhinton May 7, 2023
5c99775
allow folders to be synced recursive from SAF
newhinton May 7, 2023
8e6cf87
also support states
newhinton May 7, 2023
4d4e6db
add intents to persist uris
newhinton May 16, 2023
0eb24e1
calculate saved space for cores properly
newhinton May 16, 2023
95c508d
catch exception for size calculation when sync is not set up
newhinton Jun 10, 2023
c3b47bf
properly persist uri
newhinton Jun 10, 2023
081b443
Merge remote-tracking branch 'newhinton/feature/noid/savemanagerimpl'…
newhinton Jun 10, 2023
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions lemuroid-app-ext-free/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -16,4 +16,7 @@ dependencies {

implementation(deps.libs.retrofit)
implementation(deps.libs.kotlinxCoroutinesAndroid)
implementation("androidx.appcompat:appcompat:1.6.1")
newhinton marked this conversation as resolved.
Show resolved Hide resolved
implementation("com.google.android.material:material:1.4.0")
implementation("androidx.constraintlayout:constraintlayout:2.1.4")
}
9 changes: 7 additions & 2 deletions lemuroid-app-ext-free/src/main/AndroidManifest.xml
Original file line number Diff line number Diff line change
@@ -1,4 +1,9 @@
<manifest package="com.swordfish.lemuroid.ext"
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
tools:ignore="MissingLeanbackLauncher">
package="com.swordfish.lemuroid.ext"
tools:ignore="MissingLeanbackLauncher,ImpliedTouchscreenHardware,MissingLeanbackSupport">

<application>
<activity android:name="com.swordfish.lemuroid.ext.feature.savesync.ActivateSAFActivity"/>
</application>
</manifest>
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
package com.swordfish.lemuroid.ext.feature.savesync

import android.app.Activity
import android.content.Intent
import androidx.appcompat.app.AppCompatActivity
import android.os.Bundle
import android.widget.Toast
import androidx.activity.result.contract.ActivityResultContracts
import com.swordfish.lemuroid.ext.R
import com.swordfish.lemuroid.lib.preferences.SharedPreferencesHelper

class ActivateSAFActivity : AppCompatActivity() {
newhinton marked this conversation as resolved.
Show resolved Hide resolved

companion object {
const val PREF_KEY_STORAGE_URI_NONE = ""
}

override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_activate_safactivity)

openPicker()
}

private fun openPicker() {

val intent = Intent(Intent.ACTION_OPEN_DOCUMENT_TREE)
val resultLauncher = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result ->
when (result.resultCode) {
Activity.RESULT_OK -> {
val targetUri = result?.data?.data

if (targetUri != null ) {
contentResolver.takePersistableUriPermission(
targetUri,
Intent.FLAG_GRANT_WRITE_URI_PERMISSION
)
setStorageUri(targetUri.toString())
}
finish()
}
else -> {
Toast.makeText(this, getString(R.string.saf_save_sync_no_uri_selected), Toast.LENGTH_LONG).show()
setStorageUri(PREF_KEY_STORAGE_URI_NONE)
finish()
}
}
}

resultLauncher.launch(intent)
}

private fun setStorageUri(uri: String) {
val sharedPreferences = SharedPreferencesHelper.getSharedPreferences(this).edit()
val preferenceKey = getString(R.string.pref_key_saf_uri)
sharedPreferences.putString(preferenceKey, uri)
sharedPreferences.apply()
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -2,29 +2,224 @@ package com.swordfish.lemuroid.ext.feature.savesync

import android.app.Activity
import android.content.Context
import android.net.Uri
import android.text.format.Formatter
import androidx.documentfile.provider.DocumentFile
import com.swordfish.lemuroid.common.kotlin.SharedPreferencesDelegates
import com.swordfish.lemuroid.ext.R
import com.swordfish.lemuroid.lib.library.CoreID
import com.swordfish.lemuroid.lib.preferences.SharedPreferencesHelper
import com.swordfish.lemuroid.lib.savesync.SaveSyncManager
import com.swordfish.lemuroid.lib.storage.DirectoriesManager
import timber.log.Timber
import java.io.*
import java.text.SimpleDateFormat


class SaveSyncManagerImpl(
private val appContext: Context,
private val directoriesManager: DirectoriesManager
) : SaveSyncManager {
override fun getProvider(): String = ""

override fun getSettingsActivity(): Class<out Activity>? = null
private var lastSyncTimestamp: Long by SharedPreferencesDelegates.LongDelegate(
SharedPreferencesHelper.getSharedPreferences(appContext),
appContext.getString(R.string.pref_key_last_save_sync),
0L
)

private var storageUri: String by SharedPreferencesDelegates.StringDelegate(
SharedPreferencesHelper.getSharedPreferences(appContext),
appContext.getString(R.string.pref_key_saf_uri),
ActivateSAFActivity.PREF_KEY_STORAGE_URI_NONE
)


override fun getProvider(): String = appContext.getString(R.string.saf_save_sync_providername)

override fun getSettingsActivity(): Class<out Activity>? = ActivateSAFActivity::class.java

override fun isSupported(): Boolean = true

override fun isConfigured(): Boolean {
return storageUri != ActivateSAFActivity.PREF_KEY_STORAGE_URI_NONE
}

override fun getLastSyncInfo(): String {
val dateString = if (lastSyncTimestamp > 0) {
SimpleDateFormat.getDateTimeInstance().format(lastSyncTimestamp)
} else {
"-"
}
return appContext.getString(R.string.saf_last_sync_completed, dateString)
}

override fun getConfigInfo(): String {
return storageUri
}

/**
* Sync savegames.
*
* Todo: Sync states!
*/
override suspend fun sync(cores: Set<CoreID>) {
synchronized(SYNC_LOCK) {
val saveSyncResult = runCatching {
val safProviderUri = Uri.parse(storageUri)
val safDirectory = DocumentFile.fromTreeUri(appContext.applicationContext, safProviderUri)

if (safDirectory != null) {

// copy from saf to internal
updateInternalStorage(safDirectory)

// now copy from internal to saf
updateRemoteStorage(safDirectory)
}
lastSyncTimestamp = System.currentTimeMillis()
}

saveSyncResult.onFailure {
Timber.e(it, "Error while performing save sync.")
}
}
}

private fun updateInternalStorage(safDirectory: DocumentFile) {
val saveFiles = safDirectory.listFiles()

for (saveFile in saveFiles) {
if (!saveFile.name.isNullOrEmpty()) {
val internalTarget = getInternalSaveFile(saveFile.name!!)
if (internalTarget != null) {
if (internalTarget.lastModified() < saveFile.lastModified()) {
copyFromSafToInternal(saveFile, internalTarget)
}
} else {
val internalDir = File(appContext.getExternalFilesDir(null), "saves");
val newTarget = File(internalDir, saveFile.name)
if (newTarget.createNewFile()) {
copyFromSafToInternal(saveFile, newTarget)
} else {
Timber.e("Could not create new file in internal storage")
}
}
} else {
Timber.tag("SAF to Internal").d("Error: Remote file does not have a name!")
}
}
}

private fun updateRemoteStorage(safDirectory: DocumentFile) {
// todo: check if there is a "saves"-constant
val internalSavefiles = File(appContext.getExternalFilesDir(null), "saves").listFiles()

if (internalSavefiles != null) {
for (internalFile in internalSavefiles) {
val safTarget = safDirectory.findFile(internalFile.name)
if (safTarget != null) {
if (safTarget.lastModified() < internalFile.lastModified()) {
copyFromInternalToSaf(safTarget, internalFile)
}
} else {
val newTarget = safDirectory.createFile("application/octet-stream", internalFile.name)
if (newTarget != null) {
copyFromInternalToSaf(newTarget, internalFile)
}
}
}
}
}

private fun getInternalSaveFile(filename: String): File? {
val saves = File(appContext.getExternalFilesDir(null), "saves")
saves.mkdirs()
for (i in saves.listFiles()!!) {
if (i.name.equals(filename)) {
return File(saves, i.name)
}
}
return null
}

/**
* Copy a file from the provided DocumentFile to File via copyFile().
* File will get an updated timestamp, matching the older DocumentFile.
* File will have a backdated timestamp. This is so that the sync-client will
* not "backsync" the "newer" file in Internal Storage back to SAF even if both are identical.
*/
private fun copyFromSafToInternal(saf: DocumentFile, internal: File) {
val output: OutputStream = FileOutputStream(internal)
val input: InputStream? = appContext.contentResolver.openInputStream(saf.uri)
copyFile(input, output)
// update last modified timestamp to match (this one backdates the file)
internal.setLastModified(saf.lastModified())
}

/**
* Copy a file from the provided File to DocumentFile via copyFile().
* File will get an updated timestamp, matching the (new) DocumentFile.
* For the reasoning, see copyFromSafToInternal()
*/
private fun copyFromInternalToSaf(saf: DocumentFile, internal: File) {
val output: OutputStream? = appContext.contentResolver.openOutputStream(saf.uri)
val input: InputStream = FileInputStream(internal)
copyFile(input, output)

// update last modified timestamp to match
internal.setLastModified(saf.lastModified())
}


/**
* This function writes from input to output.
* Null-checks are performed and caught.
*/
private fun copyFile(inputStream: InputStream?, outputStream: OutputStream?) {
if (inputStream == null) {
Timber.d("SaveSyncManagerImpl: copyFile: Could not read source file!")
return
}
if (outputStream == null) {
Timber.d("SaveSyncManagerImpl: copyFile: Could not read target file!")
return
}

inputStream.use { input ->
outputStream.use { output ->
// use 8k buffer for better performance
val buf = ByteArray(8192)
var len: Int
while (input.read(buf).also { len = it } > 0) {
output.write(buf, 0, len)
}
}
}
}

override fun isSupported(): Boolean = false
override fun computeSavesSpace(): String {
var size = 0L
val safProviderUri = Uri.parse(storageUri)
val safDirectory = DocumentFile.fromTreeUri(appContext.applicationContext, safProviderUri)

override fun isConfigured(): Boolean = false
if (safDirectory != null) {

override fun getLastSyncInfo(): String = ""
val saveFiles = safDirectory.listFiles()

override fun getConfigInfo(): String = ""
for (saveFile in saveFiles) {
size += saveFile.length()
}
}
return Formatter.formatShortFileSize(appContext, size)
}

override suspend fun sync(cores: Set<CoreID>) {}
override fun computeStatesSpace(coreID: CoreID): String {
return "0"
}

override fun computeSavesSpace() = ""

override fun computeStatesSpace(coreID: CoreID) = ""
companion object {
const val GDRIVE_PROPERTY_LOCAL_PATH = "localPath"
private val SYNC_LOCK = Object()
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent"
tools:context=".feature.savesync.ActivateSAFActivity">

</androidx.constraintlayout.widget.ConstraintLayout>
3 changes: 3 additions & 0 deletions lemuroid-app-ext-free/src/main/res/values/strings.xml
Original file line number Diff line number Diff line change
Expand Up @@ -6,4 +6,7 @@
<string name="installing_core_notification_message">This can take up to a few minutes</string>

<string name="game_loader_error_load_core">The Libretro core was not loaded. Make sure your device is connected to internet and try to rescan your library. If the issue persist this core might not be supported on your device.</string>
<string name="saf_last_sync_completed">Last sync: %s</string>
<string name="saf_save_sync_providername">SAF</string>
newhinton marked this conversation as resolved.
Show resolved Hide resolved
<string name="saf_save_sync_no_uri_selected">You have not selected a folder. Sync unavailable!</string>
</resources>
1 change: 1 addition & 0 deletions retrograde-app-shared/src/main/res/values/keys.xml
Original file line number Diff line number Diff line change
Expand Up @@ -5,4 +5,5 @@
<string name="pref_key_last_save_sync" translatable="false">last_save_sync</string>
<string name="pref_key_extenral_folder" translatable="false">external_folder</string>
<string name="pref_key_legacy_external_folder" translatable="false">legacy_external_folder</string>
<string name="pref_key_saf_uri" translatable="false">pref_key_saf_uri</string>
</resources>