diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index de15c6dbd0..5247de0f29 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -9,9 +9,6 @@ SPDX-License-Identifier: GPL-3.0-or-later - - - @@ -22,7 +19,6 @@ SPDX-License-Identifier: GPL-3.0-or-later android:allowBackup="true" android:icon="@mipmap/ic_app_icon" android:label="@string/trime_app_name" - android:requestLegacyExternalStorage="true" android:roundIcon="@mipmap/ic_app_icon_round" android:theme="@style/Theme.TrimeAppTheme"> diff --git a/app/src/main/java/com/osfans/trime/core/Rime.kt b/app/src/main/java/com/osfans/trime/core/Rime.kt index aee6bf5591..b1932a63d2 100644 --- a/app/src/main/java/com/osfans/trime/core/Rime.kt +++ b/app/src/main/java/com/osfans/trime/core/Rime.kt @@ -8,8 +8,6 @@ import com.osfans.trime.data.AppPrefs import com.osfans.trime.data.base.DataManager import com.osfans.trime.data.opencc.OpenCCDictManager import com.osfans.trime.data.schema.SchemaManager -import com.osfans.trime.util.appContext -import com.osfans.trime.util.isStorageAvailable import kotlinx.coroutines.channels.BufferOverflow import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.asSharedFlow @@ -32,6 +30,9 @@ class Rime : RimeApi, RimeLifecycleOwner { override val isReady: Boolean get() = lifecycle.currentStateFlow.value == RimeLifecycle.State.READY + override val isStarting: Boolean + get() = lifecycle.currentStateFlow.value == RimeLifecycle.State.STARTING + private val dispatcher = RimeDispatcher( object : RimeDispatcher.RimeLooper { @@ -39,8 +40,8 @@ class Rime : RimeApi, RimeLifecycleOwner { DataManager.dirFireChange() DataManager.sync() - val sharedDataDir = AppPrefs.defaultInstance().profile.sharedDataDir - val userDataDir = AppPrefs.defaultInstance().profile.userDataDir + val sharedDataDir = AppPrefs.defaultInstance().profile.getAppShareDir() + val userDataDir = AppPrefs.defaultInstance().profile.getAppUserDir() Timber.i("Starting up Rime APIs ...") startupRime(sharedDataDir, userDataDir, fullCheck) @@ -84,9 +85,13 @@ class Rime : RimeApi, RimeLifecycleOwner { Timber.w("Skip starting rime: not at stopped state!") return } - if (appContext.isStorageAvailable()) { + if (AppPrefs.defaultInstance().profile.isUserDataDirChosen() && + AppPrefs.Profile.isAppPathReady() + ) { lifecycleImpl.emitState(RimeLifecycle.State.STARTING) dispatcher.start(fullCheck) + } else { + Timber.w("Skip starting rime: directory not yet ready") } } diff --git a/app/src/main/java/com/osfans/trime/core/RimeApi.kt b/app/src/main/java/com/osfans/trime/core/RimeApi.kt index 36f0a026b3..24eb8f7e97 100644 --- a/app/src/main/java/com/osfans/trime/core/RimeApi.kt +++ b/app/src/main/java/com/osfans/trime/core/RimeApi.kt @@ -13,6 +13,8 @@ interface RimeApi { val isReady: Boolean + val isStarting: Boolean + suspend fun isEmpty(): Boolean suspend fun availableSchemata(): Array diff --git a/app/src/main/java/com/osfans/trime/daemon/RimeDaemon.kt b/app/src/main/java/com/osfans/trime/daemon/RimeDaemon.kt index 0f1fb3c931..74be3da6e3 100644 --- a/app/src/main/java/com/osfans/trime/daemon/RimeDaemon.kt +++ b/app/src/main/java/com/osfans/trime/daemon/RimeDaemon.kt @@ -15,6 +15,8 @@ import com.osfans.trime.core.RimeApi import com.osfans.trime.core.RimeLifecycle import com.osfans.trime.core.lifecycleScope import com.osfans.trime.core.whenReady +import com.osfans.trime.daemon.RimeDaemon.createSession +import com.osfans.trime.daemon.RimeDaemon.destroySession import com.osfans.trime.util.appContext import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.launch @@ -122,26 +124,33 @@ object RimeDaemon { private const val CHANNEL_ID = "rime-daemon" private var restartId = 0 + private fun postNotification(id: Int) { + NotificationCompat.Builder(appContext, CHANNEL_ID) + .setSmallIcon(R.drawable.ic_baseline_sync_24) + .setContentTitle(appContext.getString(R.string.rime_daemon)) + .setContentText(appContext.getString(R.string.restarting_rime)) + .setOngoing(true) + .setProgress(100, 0, true) + .setPriority(NotificationCompat.PRIORITY_HIGH) + .build().let { notificationManager.notify(id, it) } + } + /** * Restart Rime instance to deploy while keep the session */ fun restartRime(fullCheck: Boolean = false) = lock.withLock { - val id = restartId++ - NotificationCompat.Builder(appContext, CHANNEL_ID) - .setSmallIcon(R.drawable.ic_baseline_sync_24) - .setContentTitle(appContext.getString(R.string.rime_daemon)) - .setContentText(appContext.getString(R.string.restarting_rime)) - .setOngoing(true) - .setProgress(100, 0, true) - .setPriority(NotificationCompat.PRIORITY_HIGH) - .build().let { notificationManager.notify(id, it) } realRime.finalize() realRime.startup(fullCheck) - TrimeApplication.getInstance().coroutineScope.launch { - // cancel notification on ready - realRime.lifecycle.whenReady { - notificationManager.cancel(id) + + if (realRime.isStarting) { + val id = restartId++ + postNotification(id) + TrimeApplication.getInstance().coroutineScope.launch { + // cancel notification on ready + realRime.lifecycle.whenReady { + notificationManager.cancel(id) + } } } } diff --git a/app/src/main/java/com/osfans/trime/data/AppPrefs.kt b/app/src/main/java/com/osfans/trime/data/AppPrefs.kt index ad523168e4..16594d9b11 100644 --- a/app/src/main/java/com/osfans/trime/data/AppPrefs.kt +++ b/app/src/main/java/com/osfans/trime/data/AppPrefs.kt @@ -7,8 +7,8 @@ package com.osfans.trime.data import android.content.Context import android.content.SharedPreferences import androidx.preference.PreferenceManager +import com.blankj.utilcode.util.PathUtils import com.osfans.trime.R -import com.osfans.trime.data.base.DataManager import com.osfans.trime.ime.enums.FullscreenMode import com.osfans.trime.ime.enums.InlinePreeditMode import com.osfans.trime.ime.keyboard.KeyboardPrefs @@ -347,13 +347,30 @@ class AppPrefs( const val TIMING_SYNC_TRIGGER_TIME = "profile_timing_sync_trigger_time" const val LAST_SYNC_STATUS = "profile_last_sync_status" const val LAST_BACKGROUND_SYNC = "profile_last_background_sync" + const val URI_PREFIX = "content://com.android.externalstorage.documents/" + + fun getAppPath(): String { + return PathUtils.getExternalAppFilesPath() + } + + fun isAppPathReady() = getAppPath().isNotBlank() + } + + fun getAppUserDir() = getAppPath() + "/user" + + fun getAppShareDir(): String { + return if (sharedDataDirUri.isNotEmpty() && sharedDataDirUri != userDataDirUri) { + getAppPath() + "/share" + } else { + getAppUserDir() + } } - var sharedDataDir: String - get() = prefs.getPref(SHARED_DATA_DIR, DataManager.defaultDataDirectory.path) + var sharedDataDirUri: String + get() = prefs.getPref(SHARED_DATA_DIR, "") set(v) = prefs.setPref(SHARED_DATA_DIR, v) - var userDataDir: String - get() = prefs.getPref(USER_DATA_DIR, DataManager.defaultDataDirectory.path) + var userDataDirUri: String + get() = prefs.getPref(USER_DATA_DIR, "") set(v) = prefs.setPref(USER_DATA_DIR, v) var syncBackgroundEnabled: Boolean get() = prefs.getPref(SYNC_BACKGROUND_ENABLED, false) @@ -370,6 +387,8 @@ class AppPrefs( var lastBackgroundSync: String get() = prefs.getPref(LAST_BACKGROUND_SYNC, "") set(v) = prefs.setPref(LAST_BACKGROUND_SYNC, v) + + fun isUserDataDirChosen() = userDataDirUri.isNotBlank() && userDataDirUri.startsWith(URI_PREFIX) } class Clipboard(private val prefs: AppPrefs) { diff --git a/app/src/main/java/com/osfans/trime/data/base/DataManager.kt b/app/src/main/java/com/osfans/trime/data/base/DataManager.kt index 46e493d1b9..a7868e3b0e 100644 --- a/app/src/main/java/com/osfans/trime/data/base/DataManager.kt +++ b/app/src/main/java/com/osfans/trime/data/base/DataManager.kt @@ -6,7 +6,6 @@ package com.osfans.trime.data.base import android.content.res.AssetManager import android.os.Build -import android.os.Environment import com.osfans.trime.data.AppPrefs import com.osfans.trime.util.FileUtils import com.osfans.trime.util.ResourceUtils @@ -50,8 +49,6 @@ object DataManager { private val prefs get() = AppPrefs.defaultInstance() - val defaultDataDirectory = File(Environment.getExternalStorageDirectory(), "rime") - private val onDataDirChangeListeners = WeakHashSet() fun interface OnDataDirChangeListener { @@ -72,11 +69,11 @@ object DataManager { @JvmStatic val sharedDataDir - get() = File(prefs.profile.sharedDataDir) + get() = File(prefs.profile.getAppShareDir()) @JvmStatic val userDataDir - get() = File(prefs.profile.userDataDir) + get() = File(prefs.profile.getAppUserDir()) /** * Return the absolute path of the compiled config file @@ -126,7 +123,7 @@ object DataManager { File(sharedDataDir, DEFAULT_CUSTOM_FILE_NAME).let { if (!it.exists()) { Timber.d("Creating empty default.custom.yaml") - it.bufferedWriter().use { w -> w.write("") } + it.also { it.parentFile?.mkdirs() }.bufferedWriter().use { w -> w.write("") } } } diff --git a/app/src/main/java/com/osfans/trime/data/storage/FolderExport.kt b/app/src/main/java/com/osfans/trime/data/storage/FolderExport.kt new file mode 100644 index 0000000000..b0513ff63a --- /dev/null +++ b/app/src/main/java/com/osfans/trime/data/storage/FolderExport.kt @@ -0,0 +1,119 @@ +package com.osfans.trime.data.storage + +import android.content.Context +import androidx.core.net.toUri +import androidx.documentfile.provider.DocumentFile +import com.osfans.trime.data.AppPrefs +import com.osfans.trime.util.appContext +import timber.log.Timber +import java.io.File + +class FolderExport(private val context: Context, private val docUriStr: String) { + suspend fun exportDir(dirPath: File): Boolean { + return runCatching { + DocumentFile.fromTreeUri(appContext, docUriStr.toUri())?.let { dir -> + val dirDoc = + dir.findFile(dirPath.name)?.takeIf { it.isDirectory && it.canWrite() } + ?: dir.createDirectory(dirPath.name) + + dirDoc?.let { + recursiveExport(dirPath, dirDoc) + } ?: run { + Timber.e("Error, cannot create export folder, %s", dirPath.name) + } + } + true + }.onFailure { + Timber.e(it, "Error in export") + }.getOrDefault(false) + } + + suspend fun exportModifiedFiles(fileNames: Array) { + runCatching { + DocumentFile.fromTreeUri(appContext, docUriStr.toUri())?.let { + fileNames.forEach { fileName -> + export(fileName, it) + } + } + }.onFailure { + Timber.e(it, "Error in export") + } + } + + private suspend fun recursiveExport( + sourcePath: File, + targetDir: DocumentFile, + ) { + sourcePath.listFiles()?.forEach { file -> + if (file.isFile) { + export(file, targetDir) + } else if (file.isDirectory) { + targetDir.runCatching { + val docFile = + this.findFile(file.name)?.takeIf { it.isDirectory && it.canWrite() } + ?: this.createDirectory(file.name) + + docFile?.let { + recursiveExport(file, it) + } + }.onFailure { + Timber.e(it) + } + } + } + } + + private suspend fun export( + fileName: File, + targetDir: DocumentFile, + ) { + runCatching { + val docFile = + targetDir.findFile(fileName.name)?.takeIf { it.isFile && it.canWrite() } + ?: targetDir.createFile("*/*", fileName.name) + docFile?.let { doc -> + copyToUri(fileName, doc) + } ?: run { + Timber.e("Cannot export file: %s", fileName.name) + } + }.onFailure { + Timber.e(it, "Uri Error") + } + } + + private fun copyToUri( + sourceFile: File, + targetDoc: DocumentFile, + ) { + val oss = context.contentResolver.openOutputStream(targetDoc.uri, "wt") + oss?.use { + sourceFile.inputStream().apply { + copyTo(oss) + + close() + } + } + } + + companion object { + suspend fun exportModifiedFiles() { + val profile = AppPrefs.defaultInstance().profile + val userDirUri = profile.userDataDirUri + + val f1 = File(profile.getAppUserDir(), "default.custom.yaml") + val f2 = File(profile.getAppUserDir(), "user.yaml") + + FolderExport(appContext, userDirUri).exportModifiedFiles(arrayOf(f1, f2)) + } + + suspend fun exportSyncDir(): Boolean { + val profile = AppPrefs.defaultInstance().profile + val userDirUri = profile.userDataDirUri + + val dir = "sync" + val dirFile = File(profile.getAppUserDir(), dir) + + return FolderExport(appContext, userDirUri).exportDir(dirFile) + } + } +} diff --git a/app/src/main/java/com/osfans/trime/data/storage/FolderSync.kt b/app/src/main/java/com/osfans/trime/data/storage/FolderSync.kt new file mode 100644 index 0000000000..294d172bb0 --- /dev/null +++ b/app/src/main/java/com/osfans/trime/data/storage/FolderSync.kt @@ -0,0 +1,129 @@ +package com.osfans.trime.data.storage + +import android.content.Context +import androidx.core.net.toUri +import androidx.documentfile.provider.DocumentFile +import com.osfans.trime.data.AppPrefs +import timber.log.Timber +import java.io.File + +class FolderSync(private val context: Context, private val docUriStr: String) { + private val sourceFiles = mutableSetOf() + + suspend fun copyFiles( + fileNames: Array, + appSpecificPath: String, + ) { + runCatching { + DocumentFile.fromTreeUri(context, docUriStr.toUri())?.let { tree -> + fileNames.forEach { name -> + val docFile = tree.findFile(name)?.takeIf { it.isFile } + docFile?.let { + val file = File(appSpecificPath, name) + copyToFile(it, file) + } ?: run { + Timber.w("Files %s not exists", name) + } + } + } ?: run { + Timber.w("Tree URI %s not exists", docUriStr) + } + }.onFailure { + Timber.e(it, "CopyFiles Error") + } + } + + suspend fun copyAll(appSpecificPath: String) { + runCatching { + DocumentFile.fromTreeUri(context, docUriStr.toUri())?.let { tree -> + recursivelyCopy(tree, File(appSpecificPath)) + recursiveDeleteFiles(File(appSpecificPath)) + } + }.onFailure { + Timber.e(it, "Uri (%s) Error", docUriStr) + } + } + + // IO Operation, should call in background threads + private fun recursivelyCopy( + documentTree: DocumentFile, + appSpecificPath: File, + ) { + documentTree.let { tree -> + if (!appSpecificPath.exists()) { + appSpecificPath.mkdir() + } + tree.listFiles().forEach { doc -> + if (doc.isFile) { + doc.name?.let { name -> + val file = File(appSpecificPath, name) + sourceFiles.add(file.absolutePath) + + if (shouldCopyToFile(doc, file)) { + copyToFile(doc, file) + } + } + } else if (doc.isDirectory) { + doc.name?.takeIf { it != "build" && !it.contains("userdb") }?.let { + val file = File(appSpecificPath, it) + sourceFiles.add(file.absolutePath) + recursivelyCopy(doc, file) + } + } + } + } + } + + private fun shouldCopyToFile( + sourceDoc: DocumentFile, + targetFile: File, + ): Boolean { + return !targetFile.exists() || sourceDoc.length() != targetFile.length() + } + + private fun copyToFile( + sourceDoc: DocumentFile, + targetFile: File, + ) { + val iss = context.contentResolver.openInputStream(sourceDoc.uri) + iss?.use { + targetFile.outputStream().apply { + it.copyTo(this) + close() + } +// Timber.d("Copied : ${file.absolutePath}") + } + } + + private fun recursiveDeleteFiles(path: File) { + path.listFiles()?.forEachIndexed { _, file -> + if (file.isFile) { + if (!sourceFiles.contains(file.absolutePath)) { + file.delete() + } + } else if (file.isDirectory) { + if (!file.name.contains("userdb") && file.name != "build") { + recursiveDeleteFiles(file) + if (!sourceFiles.contains(file.absolutePath)) { + file.delete() + } + } + } + } + } + + companion object { + suspend fun copyDir(context: Context) { + val userDirUri = AppPrefs.defaultInstance().profile.userDataDirUri + val shareDirUri = AppPrefs.defaultInstance().profile.sharedDataDirUri + + FolderSync(context, userDirUri) + .copyAll(AppPrefs.defaultInstance().profile.getAppUserDir()) + + if (shareDirUri != userDirUri && shareDirUri.isNotBlank()) { + FolderSync(context, shareDirUri) + .copyAll(AppPrefs.defaultInstance().profile.getAppShareDir()) + } + } + } +} diff --git a/app/src/main/java/com/osfans/trime/data/storage/StorageUtils.kt b/app/src/main/java/com/osfans/trime/data/storage/StorageUtils.kt new file mode 100644 index 0000000000..b2d4bdd85a --- /dev/null +++ b/app/src/main/java/com/osfans/trime/data/storage/StorageUtils.kt @@ -0,0 +1,42 @@ +// SPDX-FileCopyrightText: 2015 - 2024 Rime community +// +// SPDX-License-Identifier: GPL-3.0-or-later + +package com.osfans.trime.data.storage + +import android.content.Context +import android.content.Intent +import android.provider.DocumentsContract + +object StorageUtils { + private fun getDocumentsUiPackage(context: Context): String? { + // See android.permission.cts.ProviderPermissionTest.testManageDocuments() + val packageInfos = + context.packageManager.getPackagesHoldingPermissions( + arrayOf(android.Manifest.permission.MANAGE_DOCUMENTS), + 0, + ) + val packageInfo = + packageInfos.firstOrNull { it.packageName.endsWith(".documentsui") } + ?: packageInfos.firstOrNull() + return packageInfo?.packageName + } + + fun getViewDirIntent(context: Context): Intent? { + val viewIntent = + Intent(Intent.ACTION_VIEW).apply { + setDataAndType( + DocumentsContract.buildRootsUri("${context.packageName}.provider"), + DocumentsContract.Document.MIME_TYPE_DIR, + ) + addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) +// addCategory(Intent.CATEGORY_OPENABLE) + getDocumentsUiPackage(context)?.let { setPackage(it) } + } + return if (viewIntent.resolveActivity(context.packageManager) != null) { + viewIntent + } else { + null + } + } +} diff --git a/app/src/main/java/com/osfans/trime/data/theme/ThemeManager.kt b/app/src/main/java/com/osfans/trime/data/theme/ThemeManager.kt index c622aa1b84..28b5c7aa95 100644 --- a/app/src/main/java/com/osfans/trime/data/theme/ThemeManager.kt +++ b/app/src/main/java/com/osfans/trime/data/theme/ThemeManager.kt @@ -15,6 +15,11 @@ object ThemeManager { fun onThemeChange(theme: Theme) } + private const val SHARE_TYPE = "SHARE" + private const val USER_TYPE = "USER" + const val DEFAULT_THEME = "trime" + const val TONGWENFENG_THEME = "tongwenfeng" + /** * Update sharedThemes and userThemes. */ @@ -32,27 +37,56 @@ object ThemeManager { private fun listThemes(path: File): MutableList { return path.listFiles { _, name -> name.endsWith("trime.yaml") } ?.mapNotNull { f -> - if (f.name == "trime.yaml") "trime" else f.name.substringBeforeLast(".trime.yaml") + getThemeName(f) } ?.toMutableList() ?: mutableListOf() } - private val sharedThemes: MutableList get() = listThemes(DataManager.sharedDataDir) + private fun getThemeName(f: File): String { + val name = f.name.substringBeforeLast(".trime.yaml") + return if (name == "trime.yaml") { + "trime" + } else { + name + } + } + + private fun getSharedThemes(): List = listThemes(DataManager.sharedDataDir).sorted() - private val userThemes: MutableList get() = listThemes(DataManager.userDataDir) + private fun getUserThemes(): List = listThemes(DataManager.userDataDir).sorted() - fun getAllThemes(): List { + fun getAllThemes(): List> { + val userThemes = getUserThemes() if (DataManager.sharedDataDir.absolutePath == DataManager.userDataDir.absolutePath) { - return userThemes + return userThemes.map { Pair(it, USER_TYPE) } + } + + // if same theme exists in both user & share dir, user dir will be used + val allThemes = userThemes.map { Pair(it, USER_TYPE) }.toMutableList() + getSharedThemes().forEach { + if (!userThemes.contains(it)) { + allThemes.add(Pair(it, SHARE_TYPE)) + } + } + + return allThemes.also { + moveThemeToFirst(it, TONGWENFENG_THEME) + moveThemeToFirst(it, DEFAULT_THEME) + } + } + + private fun moveThemeToFirst( + themes: MutableList>, + themeName: String, + ) { + val defaultThemeIdx = themes.indexOfFirst { it.first == themeName } + if (defaultThemeIdx > 0) { + val pair = themes.removeAt(defaultThemeIdx) + themes.add(0, pair) } - return sharedThemes + userThemes } private fun refreshThemes() { - sharedThemes.clear() - userThemes.clear() - sharedThemes.addAll(listThemes(DataManager.sharedDataDir)) - userThemes.addAll(listThemes(DataManager.userDataDir)) } // 在初始化 ColorManager 时会被赋值 @@ -76,4 +110,8 @@ object ThemeManager { TabManager.refresh() } } + + fun isUserTheme(theme: Pair): Boolean { + return theme.second == USER_TYPE + } } diff --git a/app/src/main/java/com/osfans/trime/ime/broadcast/ExternalStorageStateReceiver.kt b/app/src/main/java/com/osfans/trime/ime/broadcast/ExternalStorageStateReceiver.kt new file mode 100644 index 0000000000..7ef198b19b --- /dev/null +++ b/app/src/main/java/com/osfans/trime/ime/broadcast/ExternalStorageStateReceiver.kt @@ -0,0 +1,57 @@ +// SPDX-FileCopyrightText: 2015 - 2024 Rime community +// +// SPDX-License-Identifier: GPL-3.0-or-later + +package com.osfans.trime.ime.broadcast + +import android.content.BroadcastReceiver +import android.content.Context +import android.content.Intent +import android.content.IntentFilter +import android.os.Environment +import com.osfans.trime.data.AppPrefs + +class ExternalStorageStateReceiver( + private val context: Context, + private val mountedRunFunc: () -> Unit, +) { + private val externalStorageStateReceiver: BroadcastReceiver = + object : BroadcastReceiver() { + override fun onReceive( + context: Context, + intent: Intent, + ) { + updateExternalStorageState(intent) + } + } + + private fun updateExternalStorageState(intent: Intent) { + val state = Environment.getExternalStorageState() + if (Environment.MEDIA_MOUNTED == state) { + // SD card is mounted and ready for use + intent.data?.path?.let { path -> + if (AppPrefs.Profile.getAppPath().startsWith(path)) { + mountedRunFunc() + context.unregisterReceiver(externalStorageStateReceiver) + } + } + } else if (Environment.MEDIA_MOUNTED_READ_ONLY == state) { + // SD card is mounted, but it is read only + } else { + // SD card is unavailable + } + } + + fun listenExternalStorageChangeState() { + val state = Environment.getExternalStorageState() + if (state != Environment.MEDIA_MOUNTED) { + context.registerReceiver( + externalStorageStateReceiver, + IntentFilter().apply { + addAction(Intent.ACTION_MEDIA_MOUNTED) + addDataScheme("file") + }, + ) + } + } +} diff --git a/app/src/main/java/com/osfans/trime/ime/broadcast/IntentReceiver.kt b/app/src/main/java/com/osfans/trime/ime/broadcast/IntentReceiver.kt index b5adc7040c..e10d2f96cb 100644 --- a/app/src/main/java/com/osfans/trime/ime/broadcast/IntentReceiver.kt +++ b/app/src/main/java/com/osfans/trime/ime/broadcast/IntentReceiver.kt @@ -19,6 +19,7 @@ import com.osfans.trime.R import com.osfans.trime.core.Rime import com.osfans.trime.daemon.RimeDaemon import com.osfans.trime.data.AppPrefs +import com.osfans.trime.util.ShortcutUtils import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.MainScope @@ -92,8 +93,7 @@ class IntentReceiver : BroadcastReceiver(), CoroutineScope by MainScope() { } } - Rime.syncRimeUserData() - RimeDaemon.restartRime(true) + ShortcutUtils.sync(true) wakeLock.release() // 释放唤醒锁 } } diff --git a/app/src/main/java/com/osfans/trime/ime/core/TrimeInputMethodService.kt b/app/src/main/java/com/osfans/trime/ime/core/TrimeInputMethodService.kt index 49c537da56..cc50fc076f 100644 --- a/app/src/main/java/com/osfans/trime/ime/core/TrimeInputMethodService.kt +++ b/app/src/main/java/com/osfans/trime/ime/core/TrimeInputMethodService.kt @@ -41,6 +41,7 @@ import com.osfans.trime.data.AppPrefs import com.osfans.trime.data.db.DraftHelper import com.osfans.trime.data.theme.ColorManager import com.osfans.trime.data.theme.ThemeManager +import com.osfans.trime.ime.broadcast.ExternalStorageStateReceiver import com.osfans.trime.ime.broadcast.IntentReceiver import com.osfans.trime.ime.composition.CompositionPopupWindow import com.osfans.trime.ime.enums.FullscreenMode @@ -218,6 +219,15 @@ open class TrimeInputMethodService : LifecycleInputMethodService() { } override fun onCreate() { + val stateReceiver = + ExternalStorageStateReceiver(this) { + if (!rime.run { isReady }) { + RimeDaemon.destroySession(javaClass.name) + rime = RimeDaemon.createSession(javaClass.name) + } + } + stateReceiver.listenExternalStorageChangeState() + rime = RimeDaemon.createSession(javaClass.name) super.onCreate() // MUST WRAP all code within Service onCreate() in try..catch to prevent any crash loops diff --git a/app/src/main/java/com/osfans/trime/ime/dialog/AvailableSchemaPickerDialog.kt b/app/src/main/java/com/osfans/trime/ime/dialog/AvailableSchemaPickerDialog.kt index cd57ca93e9..d6d253c0a9 100644 --- a/app/src/main/java/com/osfans/trime/ime/dialog/AvailableSchemaPickerDialog.kt +++ b/app/src/main/java/com/osfans/trime/ime/dialog/AvailableSchemaPickerDialog.kt @@ -10,8 +10,8 @@ import androidx.lifecycle.LifecycleCoroutineScope import com.osfans.trime.R import com.osfans.trime.core.RimeApi import com.osfans.trime.daemon.RimeDaemon +import com.osfans.trime.data.storage.FolderExport import kotlinx.coroutines.launch -import kotlinx.coroutines.plus object AvailableSchemaPickerDialog { suspend fun build( @@ -37,6 +37,7 @@ object AvailableSchemaPickerDialog { if (setOf(newEnabled) == setOf(enabledIds)) return@setPositiveButton scope.launch { rime.setEnabledSchemata(newEnabled.toTypedArray()) + FolderExport.exportModifiedFiles() RimeDaemon.restartRime() } } diff --git a/app/src/main/java/com/osfans/trime/ime/dialog/EnabledSchemaPickerDialog.kt b/app/src/main/java/com/osfans/trime/ime/dialog/EnabledSchemaPickerDialog.kt index 77f32995a0..c7ccc4dae6 100644 --- a/app/src/main/java/com/osfans/trime/ime/dialog/EnabledSchemaPickerDialog.kt +++ b/app/src/main/java/com/osfans/trime/ime/dialog/EnabledSchemaPickerDialog.kt @@ -9,6 +9,7 @@ import android.content.Context import androidx.lifecycle.LifecycleCoroutineScope import com.osfans.trime.R import com.osfans.trime.core.RimeApi +import com.osfans.trime.data.storage.FolderExport import kotlinx.coroutines.launch import splitties.systemservices.inputMethodManager @@ -35,6 +36,7 @@ object EnabledSchemaPickerDialog { ) { dialog, which -> scope.launch { rime.selectSchema(selectedIds[which]) + FolderExport.exportModifiedFiles() dialog.dismiss() } } diff --git a/app/src/main/java/com/osfans/trime/ime/keyboard/InitializationUi.kt b/app/src/main/java/com/osfans/trime/ime/keyboard/InitializationUi.kt index db50f3903b..15d7b1321c 100644 --- a/app/src/main/java/com/osfans/trime/ime/keyboard/InitializationUi.kt +++ b/app/src/main/java/com/osfans/trime/ime/keyboard/InitializationUi.kt @@ -8,8 +8,7 @@ import android.content.Context import android.view.View import android.widget.ProgressBar import com.osfans.trime.R -import com.osfans.trime.util.appContext -import com.osfans.trime.util.isStorageAvailable +import com.osfans.trime.data.AppPrefs import splitties.dimensions.dp import splitties.resources.color import splitties.views.backgroundColor @@ -41,10 +40,10 @@ class InitializationUi(override val ctx: Context) : Ui { val textView = textView { textResource = - if (appContext.isStorageAvailable()) { + if (isDeploying()) { R.string.deploy_progress } else { - R.string.external_storage_permission_not_available + R.string.directory_not_selected } textSize = 24f textAlignment = View.TEXT_ALIGNMENT_CENTER @@ -55,7 +54,7 @@ class InitializationUi(override val ctx: Context) : Ui { ProgressBar(context, null, android.R.attr.progressBarStyleHorizontal).apply { isIndeterminate = true visibility = - if (appContext.isStorageAvailable()) { + if (isDeploying()) { View.VISIBLE } else { View.GONE @@ -78,6 +77,8 @@ class InitializationUi(override val ctx: Context) : Ui { ) } + private fun isDeploying() = AppPrefs.defaultInstance().profile.isUserDataDirChosen() + override val root = constraintLayout { add( diff --git a/app/src/main/java/com/osfans/trime/provider/RimeDataProvider.kt b/app/src/main/java/com/osfans/trime/provider/RimeDataProvider.kt index 22dad6e39c..7a7ae4de56 100644 --- a/app/src/main/java/com/osfans/trime/provider/RimeDataProvider.kt +++ b/app/src/main/java/com/osfans/trime/provider/RimeDataProvider.kt @@ -15,6 +15,7 @@ import android.provider.DocumentsContract.Root import android.provider.DocumentsProvider import android.webkit.MimeTypeMap import com.osfans.trime.R +import com.osfans.trime.data.AppPrefs import java.io.File import java.io.FileNotFoundException import java.io.IOException @@ -72,7 +73,7 @@ class RimeDataProvider : DocumentsProvider() { private fun fileFromDocId(docId: String) = File(docIdPrefix, docId) override fun onCreate(): Boolean { - baseDir = context!!.getExternalFilesDir(null)!! + baseDir = File(AppPrefs.Profile.getAppPath()) docIdPrefix = "${baseDir.parent}${File.separator}" textFilePaths = Array(TEXT_FILES.size) { baseDir.resolve(TEXT_FILES[it]).absolutePath } return true diff --git a/app/src/main/java/com/osfans/trime/ui/components/FolderPickerPreference.kt b/app/src/main/java/com/osfans/trime/ui/components/FolderPickerPreference.kt index e1a90b4baf..f17eba832d 100644 --- a/app/src/main/java/com/osfans/trime/ui/components/FolderPickerPreference.kt +++ b/app/src/main/java/com/osfans/trime/ui/components/FolderPickerPreference.kt @@ -5,17 +5,16 @@ package com.osfans.trime.ui.components import android.content.Context +import android.content.Intent import android.content.res.TypedArray -import android.net.Uri import android.util.AttributeSet import android.view.LayoutInflater import androidx.activity.result.ActivityResultLauncher import androidx.appcompat.app.AlertDialog import androidx.preference.Preference -import com.blankj.utilcode.util.UriUtils import com.osfans.trime.R import com.osfans.trime.databinding.FolderPickerDialogBinding -import java.io.File +import com.osfans.trime.ui.setup.SetupFragment class FolderPickerPreference @JvmOverloads @@ -25,8 +24,12 @@ class FolderPickerPreference defStyleAttr: Int = androidx.preference.R.attr.preferenceStyle, ) : Preference(context, attrs, defStyleAttr) { private var value = "" - lateinit var documentTreeLauncher: ActivityResultLauncher + lateinit var documentTreeLauncher: ActivityResultLauncher lateinit var dialogView: FolderPickerDialogBinding + private var tempValue = "" + private var showDefaultButton = true + private var defaultButtonLabel: String + private var alertDialog: AlertDialog? = null var default = "" @@ -34,8 +37,12 @@ class FolderPickerPreference context.theme.obtainStyledAttributes(attrs, R.styleable.FolderPickerPreferenceAttrs, 0, 0).run { try { if (getBoolean(R.styleable.FolderPickerPreferenceAttrs_useSimpleSummaryProvider, false)) { - summaryProvider = SummaryProvider { it.value } + summaryProvider = SummaryProvider { getDisplayValue(it.value) } } + showDefaultButton = getBoolean(R.styleable.FolderPickerPreferenceAttrs_showDefaultButton, true) + defaultButtonLabel = getString( + R.styleable.FolderPickerPreferenceAttrs_defaultButtonLabel, + ) ?: context.getString(R.string.pref__default) } finally { recycle() } @@ -67,24 +74,26 @@ class FolderPickerPreference override fun onClick() = showPickerDialog() private fun showPickerDialog() { - val initValue = value + val initValue = getDisplayValue(value) dialogView = FolderPickerDialogBinding.inflate(LayoutInflater.from(context)) dialogView.editText.setText(initValue) dialogView.button.setOnClickListener { - documentTreeLauncher.launch(UriUtils.file2Uri(File(initValue))) + documentTreeLauncher.launch(SetupFragment.getFolderIntent()) } - AlertDialog.Builder(context) - .setTitle(this@FolderPickerPreference.title) - .setView(dialogView.root) - .setPositiveButton(android.R.string.ok) { _, _ -> - val value = dialogView.editText.text.toString() - setValue(value) - } - .setNeutralButton(R.string.pref__default) { _, _ -> + val builder = + AlertDialog.Builder(context) + .setTitle(this@FolderPickerPreference.title) + .setView(dialogView.root) + .setPositiveButton(android.R.string.ok, null) + + if (showDefaultButton) { + builder.setNeutralButton(defaultButtonLabel) { _, _ -> setValue(default) } - .setNegativeButton(android.R.string.cancel, null) - .show() + } + + alertDialog = builder.create() + alertDialog?.show() } private fun setValue(value: String) { @@ -93,4 +102,19 @@ class FolderPickerPreference notifyChanged() } } + + fun saveValueAndClose(value: String) { + if (value.isNotBlank()) { + setValue(value) + } + alertDialog?.dismiss() + } + + private fun getDisplayValue(value: String): String { + return if (value.isBlank()) { + "" + } else { + value.split("%3A").last().replace("%2F", "/") + } + } } diff --git a/app/src/main/java/com/osfans/trime/ui/fragments/PrefFragment.kt b/app/src/main/java/com/osfans/trime/ui/fragments/PrefFragment.kt index 067037157e..709c50897a 100644 --- a/app/src/main/java/com/osfans/trime/ui/fragments/PrefFragment.kt +++ b/app/src/main/java/com/osfans/trime/ui/fragments/PrefFragment.kt @@ -5,6 +5,7 @@ package com.osfans.trime.ui.fragments import android.os.Bundle +import androidx.appcompat.app.AlertDialog import androidx.fragment.app.activityViewModels import androidx.lifecycle.lifecycleScope import androidx.navigation.fragment.findNavController @@ -39,16 +40,13 @@ class PrefFragment : PaddingPreferenceFragment() { setPreferencesFromResource(R.xml.prefs, rootKey) with(preferenceScreen) { get("pref_schemata")?.setOnPreferenceClickListener { - viewModel.rime.launchOnReady { api -> - lifecycleScope.launch { - EnabledSchemaPickerDialog.build(api, lifecycleScope, context) { - setPositiveButton(R.string.enable_schemata) { _, _ -> - lifecycleScope.launch { - AvailableSchemaPickerDialog.build(api, lifecycleScope, context).show() - } - } - }.show() - } + if (viewModel.rime.run { !isStarting && !isReady }) { + AlertDialog.Builder(context) + .setMessage(R.string.no_schema_before_deploy) + .setPositiveButton(R.string.ok, null) + .show() + } else { + showSchemaPicker() } true } @@ -78,4 +76,22 @@ class PrefFragment : PaddingPreferenceFragment() { } } } + + private fun showSchemaPicker() { + viewModel.rime.launchOnReady { api -> + lifecycleScope.launch { + EnabledSchemaPickerDialog.build(api, lifecycleScope, requireContext()) { + setPositiveButton(R.string.enable_schemata) { _, _ -> + lifecycleScope.launch { + AvailableSchemaPickerDialog.build( + api, + lifecycleScope, + context, + ).show() + } + } + }.show() + } + } + } } diff --git a/app/src/main/java/com/osfans/trime/ui/fragments/ProfileFragment.kt b/app/src/main/java/com/osfans/trime/ui/fragments/ProfileFragment.kt index 42f755f6a3..e382517318 100644 --- a/app/src/main/java/com/osfans/trime/ui/fragments/ProfileFragment.kt +++ b/app/src/main/java/com/osfans/trime/ui/fragments/ProfileFragment.kt @@ -4,6 +4,7 @@ package com.osfans.trime.ui.fragments +import android.app.Activity import android.app.AlarmManager import android.app.PendingIntent import android.app.TimePickerDialog @@ -12,7 +13,6 @@ import android.content.SharedPreferences import android.os.Build.VERSION import android.os.Build.VERSION_CODES import android.os.Bundle -import android.provider.DocumentsContract import androidx.activity.result.contract.ActivityResultContracts import androidx.appcompat.app.AlertDialog import androidx.fragment.app.activityViewModels @@ -21,16 +21,14 @@ import androidx.preference.Preference import androidx.preference.SwitchPreferenceCompat import androidx.preference.get import com.blankj.utilcode.util.ToastUtils -import com.blankj.utilcode.util.UriUtils import com.osfans.trime.R -import com.osfans.trime.core.Rime -import com.osfans.trime.daemon.RimeDaemon import com.osfans.trime.data.AppPrefs import com.osfans.trime.data.base.DataManager import com.osfans.trime.ui.components.FolderPickerPreference import com.osfans.trime.ui.components.PaddingPreferenceFragment import com.osfans.trime.ui.main.MainViewModel import com.osfans.trime.util.ResourceUtils +import com.osfans.trime.util.ShortcutUtils import com.osfans.trime.util.appContext import com.osfans.trime.util.formatDateTime import com.osfans.trime.util.rimeActionWithResultDialog @@ -50,14 +48,20 @@ class ProfileFragment : private fun FolderPickerPreference.registerDocumentTreeLauncher() { documentTreeLauncher = - registerForActivityResult(ActivityResultContracts.OpenDocumentTree()) { - it ?: return@registerForActivityResult - val uri = - DocumentsContract.buildDocumentUriUsingTree( - it, - DocumentsContract.getTreeDocumentId(it), - ) - dialogView.editText.setText(UriUtils.uri2File(uri).absolutePath) + registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result -> + if (result.resultCode == Activity.RESULT_OK) { + val context = requireContext() + result.data?.data?.also { uri -> + val contentResolver = context.contentResolver + + val takeFlags: Int = + Intent.FLAG_GRANT_READ_URI_PERMISSION or + Intent.FLAG_GRANT_WRITE_URI_PERMISSION + contentResolver.takePersistableUriPermission(uri, takeFlags) + + saveValueAndClose(uri.toString()) + } + } } } @@ -67,25 +71,24 @@ class ProfileFragment : ) { addPreferencesFromResource(R.xml.profile_preference) with(preferenceScreen) { - get("profile_shared_data_dir")?.apply { - setDefaultValue(DataManager.defaultDataDirectory.path) + get(AppPrefs.Profile.SHARED_DATA_DIR)?.apply { registerDocumentTreeLauncher() } - get("profile_user_data_dir")?.apply { - setDefaultValue(DataManager.defaultDataDirectory.path) + get(AppPrefs.Profile.USER_DATA_DIR)?.apply { registerDocumentTreeLauncher() } get("profile_sync_user_data")?.setOnPreferenceClickListener { lifecycleScope.launch { this@ProfileFragment.context?.rimeActionWithResultDialog("rime.trime", "W", 1) { - Rime.syncRimeUserData() - RimeDaemon.restartRime(true) - true + viewModel.setToLoading() + val result = ShortcutUtils.sync(true) + viewModel.setToReady() + result } } true } - get("profile_sync_in_background")?.apply { + get(AppPrefs.Profile.SYNC_BACKGROUND_ENABLED)?.apply { val lastBackgroundSync = prefs.profile.lastBackgroundSync summaryOn = if (lastBackgroundSync.isBlank()) { @@ -105,7 +108,7 @@ class ProfileFragment : } summaryOff = context.getString(R.string.profile_enable_syncing_in_background) } - get("profile_timing_sync")?.apply { // 定时同步偏好描述 + get(AppPrefs.Profile.TIMING_SYNC_ENABLED)?.apply { // 定时同步偏好描述 val timingSyncPreference: SwitchPreferenceCompat? = findPreference("profile_timing_sync") timingSyncPreference?.summaryProvider = Preference.SummaryProvider { @@ -119,7 +122,7 @@ class ProfileFragment : } } } - get("profile_timing_sync")?.setOnPreferenceClickListener { // 监听定时同步偏好设置 + get(AppPrefs.Profile.TIMING_SYNC_ENABLED)?.setOnPreferenceClickListener { // 监听定时同步偏好设置 // 设置待发送的同步事件 val pendingIntent = PendingIntent.getBroadcast( @@ -133,7 +136,7 @@ class ProfileFragment : }, ) val cal = Calendar.getInstance() - if (get("profile_timing_sync")?.isChecked == true) { // 当定时同步偏好打开时 + if (get(AppPrefs.Profile.TIMING_SYNC_ENABLED)?.isChecked == true) { // 当定时同步偏好打开时 val timeSetListener = // 监听时间选择器设置 TimePickerDialog.OnTimeSetListener { _, hour, minute -> cal.set(Calendar.HOUR_OF_DAY, hour) @@ -235,7 +238,7 @@ class ProfileFragment : ) { // 实时更新定时同步偏好描述 val timingSyncPreference: SwitchPreferenceCompat? = findPreference("profile_timing_sync") when (key) { - "profile_timing_sync_trigger_time", + AppPrefs.Profile.TIMING_SYNC_TRIGGER_TIME, -> { timingSyncPreference?.summaryProvider = Preference.SummaryProvider { diff --git a/app/src/main/java/com/osfans/trime/ui/fragments/ToolkitFragment.kt b/app/src/main/java/com/osfans/trime/ui/fragments/ToolkitFragment.kt index 6aaebd5881..1b9e3b3ebe 100644 --- a/app/src/main/java/com/osfans/trime/ui/fragments/ToolkitFragment.kt +++ b/app/src/main/java/com/osfans/trime/ui/fragments/ToolkitFragment.kt @@ -8,7 +8,9 @@ import android.app.AlertDialog import android.os.Bundle import androidx.fragment.app.activityViewModels import androidx.preference.Preference +import com.blankj.utilcode.util.ToastUtils import com.osfans.trime.R +import com.osfans.trime.data.storage.StorageUtils import com.osfans.trime.ui.components.PaddingPreferenceFragment import com.osfans.trime.ui.main.MainViewModel import com.osfans.trime.util.Logcat @@ -48,6 +50,21 @@ class ToolkitFragment : PaddingPreferenceFragment() { } }, ) + screen.addPreference( + Preference(context).apply { + setTitle(R.string.pref_toolkit_internal_dir) + isIconSpaceReserved = false + setOnPreferenceClickListener { + val intent = StorageUtils.getViewDirIntent(context) + intent?.let { + startActivity(it) + } ?: run { + ToastUtils.showShort("No Document App Found") + } + true + } + }, + ) preferenceScreen = screen } diff --git a/app/src/main/java/com/osfans/trime/ui/main/MainViewModel.kt b/app/src/main/java/com/osfans/trime/ui/main/MainViewModel.kt index 15ebb4ec57..ea6a1890ab 100644 --- a/app/src/main/java/com/osfans/trime/ui/main/MainViewModel.kt +++ b/app/src/main/java/com/osfans/trime/ui/main/MainViewModel.kt @@ -8,8 +8,20 @@ import androidx.lifecycle.MutableLiveData import androidx.lifecycle.ViewModel import com.osfans.trime.daemon.RimeDaemon import com.osfans.trime.daemon.RimeSession +import com.osfans.trime.data.AppPrefs +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asStateFlow + +enum class MainUiState { + LOADING, + READY, + ERR_DIRECTORY_MISSING, +} class MainViewModel : ViewModel() { + private val _statusStateFlow = MutableStateFlow(MainUiState.READY) + val statusStateFlow = _statusStateFlow.asStateFlow() + val toolbarTitle = MutableLiveData() val topOptionsMenu = MutableLiveData() @@ -31,4 +43,32 @@ class MainViewModel : ViewModel() { override fun onCleared() { RimeDaemon.destroySession(javaClass.name) } + + fun setToLoading() { + _statusStateFlow.value = MainUiState.LOADING + } + + fun setToReady() { + _statusStateFlow.value = MainUiState.READY + } + + fun setToError() { + _statusStateFlow.value = MainUiState.ERR_DIRECTORY_MISSING + } + + fun checkAndResetPathPermission( + persistedUriList: List, + userDirUri: String, + shareDirUri: String, + ): Boolean { + return if (!persistedUriList.contains(userDirUri)) { + AppPrefs.defaultInstance().profile.userDataDirUri = "" + false + } else if (shareDirUri.isNotBlank() && !persistedUriList.contains(shareDirUri)) { + AppPrefs.defaultInstance().profile.sharedDataDirUri = "" + false + } else { + true + } + } } diff --git a/app/src/main/java/com/osfans/trime/ui/main/PrefMainActivity.kt b/app/src/main/java/com/osfans/trime/ui/main/PrefMainActivity.kt index aec3a7219a..c45e796f0f 100644 --- a/app/src/main/java/com/osfans/trime/ui/main/PrefMainActivity.kt +++ b/app/src/main/java/com/osfans/trime/ui/main/PrefMainActivity.kt @@ -14,6 +14,7 @@ import android.view.MenuItem import android.view.ViewGroup import androidx.activity.enableEdgeToEdge import androidx.activity.viewModels +import androidx.annotation.UiThread import androidx.appcompat.app.AppCompatActivity import androidx.appcompat.app.AppCompatDelegate import androidx.core.view.ViewCompat @@ -34,14 +35,16 @@ import com.osfans.trime.core.RimeLifecycle import com.osfans.trime.daemon.RimeDaemon import com.osfans.trime.data.AppPrefs import com.osfans.trime.data.sound.SoundEffectManager +import com.osfans.trime.data.storage.FolderSync import com.osfans.trime.databinding.ActivityPrefBinding import com.osfans.trime.ui.setup.SetupActivity -import com.osfans.trime.util.isStorageAvailable import com.osfans.trime.util.progressBarDialogIndeterminate import com.osfans.trime.util.rimeActionWithResultDialog +import kotlinx.coroutines.flow.combine import kotlinx.coroutines.launch import splitties.systemservices.alarmManager import splitties.views.topPadding +import timber.log.Timber class PrefMainActivity : AppCompatActivity() { private val viewModel: MainViewModel by viewModels() @@ -116,16 +119,42 @@ class PrefMainActivity : AppCompatActivity() { lifecycleScope.launch { repeatOnLifecycle(Lifecycle.State.STARTED) { - viewModel.rime.run { stateFlow }.collect { state -> + viewModel.rime.run { stateFlow }.combine(viewModel.statusStateFlow) { rimeStateFlow, viewModelFlow -> + val final = + if (viewModelFlow == MainUiState.ERR_DIRECTORY_MISSING) { + viewModelFlow + } else if (rimeStateFlow == RimeLifecycle.State.STARTING || viewModelFlow == MainUiState.LOADING) { + MainUiState.LOADING + } else if (rimeStateFlow == RimeLifecycle.State.READY) { + MainUiState.READY + } else { + MainUiState.READY + } + final + }.collect { state -> + Timber.d("UI State is %s", state) when (state) { - RimeLifecycle.State.STARTING -> { + MainUiState.LOADING -> { + loadingDialog?.let { + // if dialog is not null, do nothing, we don't want to dismiss and recreate loading dialog + } ?: run { + // if dialog is null, create and show + loadingDialog = + progressBarDialogIndeterminate(R.string.deploy_progress).create().apply { + show() + } + } + } + MainUiState.READY -> { + loadingDialog?.dismiss() + loadingDialog = null + } + MainUiState.ERR_DIRECTORY_MISSING -> { loadingDialog?.dismiss() - loadingDialog = - progressBarDialogIndeterminate(R.string.deploy_progress).create().apply { - show() - } + loadingDialog = null + viewModel.setToReady() + showNoDirectoryAlert() } - RimeLifecycle.State.READY -> loadingDialog?.dismiss() else -> return@collect } } @@ -158,15 +187,50 @@ class PrefMainActivity : AppCompatActivity() { private fun deploy() { lifecycleScope.launch { rimeActionWithResultDialog("rime.trime", "W", 1) { - RimeDaemon.restartRime(true) + viewModel.setToLoading() + if (copyToInternal()) { + RimeDaemon.restartRime(true) + viewModel.setToReady() + } else { + viewModel.setToError() + } true } } } + private suspend fun copyToInternal(): Boolean { + val allowedUriList = + contentResolver.persistedUriPermissions.map { + it.uri.toString() + } + + val userDirUri = AppPrefs.defaultInstance().profile.userDataDirUri + val shareDirUri = AppPrefs.defaultInstance().profile.sharedDataDirUri + + return if (viewModel.checkAndResetPathPermission(allowedUriList, userDirUri, shareDirUri)) { + FolderSync.copyDir(this) + true + } else { + false + } + } + + @UiThread + private fun showNoDirectoryAlert() { + AlertDialog.Builder(this) + .setTitle(R.string.directory_not_selected) + .setMessage(R.string.profile__error_user_data_dir) + .setPositiveButton(R.string.ok) { _, _ -> + navHostFragment.navController.navigate(R.id.action_prefFragment_to_profileFragment) + } + .setCancelable(false) + .show() + } + override fun onResume() { super.onResume() - if (isStorageAvailable()) { + if (AppPrefs.defaultInstance().profile.isUserDataDirChosen()) { SoundEffectManager.init() } } diff --git a/app/src/main/java/com/osfans/trime/ui/main/settings/ThemePickerDialog.kt b/app/src/main/java/com/osfans/trime/ui/main/settings/ThemePickerDialog.kt index 609069b9d6..046cab3917 100644 --- a/app/src/main/java/com/osfans/trime/ui/main/settings/ThemePickerDialog.kt +++ b/app/src/main/java/com/osfans/trime/ui/main/settings/ThemePickerDialog.kt @@ -9,6 +9,7 @@ import android.content.Context import androidx.lifecycle.LifecycleCoroutineScope import com.osfans.trime.R import com.osfans.trime.data.AppPrefs +import com.osfans.trime.data.storage.FolderSync import com.osfans.trime.data.theme.ThemeManager import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch @@ -25,15 +26,19 @@ object ThemePickerDialog { } val allNames = all.map { - when (it) { - "trime" -> context.getString(R.string.theme_trime) - "tongwenfeng" -> context.getString(R.string.theme_tongwenfeng) - else -> it + when (val themeName = it.first) { + ThemeManager.DEFAULT_THEME -> context.getString(R.string.theme_trime) + ThemeManager.TONGWENFENG_THEME -> context.getString(R.string.theme_tongwenfeng) + else -> + if (ThemeManager.isUserTheme(it)) { + themeName + } else { + "[${context.getString(R.string.share)}] $themeName" + } } } - val current = - AppPrefs.defaultInstance().theme.selectedTheme - val currentIndex = all.indexOfFirst { it == current } + val current = AppPrefs.defaultInstance().theme.selectedTheme + val currentIndex = all.indexOfFirst { it.first == current }.coerceAtLeast(0) return AlertDialog.Builder(context).apply { setTitle(R.string.looks__selected_theme_title) if (allNames.isEmpty()) { @@ -44,12 +49,49 @@ object ThemePickerDialog { currentIndex, ) { dialog, which -> scope.launch { - ThemeManager.setNormalTheme(all[which]) dialog.dismiss() + withContext(Dispatchers.IO) { + copyThemeFile(context, all[which]) + ThemeManager.setNormalTheme(all[which].first) + } } } } setNegativeButton(android.R.string.cancel, null) }.create() } + + private suspend fun copyThemeFile( + context: Context, + selectedTheme: Pair, + ) { + val themeName = selectedTheme.first + val fileNameWithoutExt = + if (themeName == "trime") { + "trime" + } else { + "$themeName.trime" + } + + val profile = AppPrefs.defaultInstance().profile + val uri = + if (ThemeManager.isUserTheme(selectedTheme)) { + profile.userDataDirUri + } else { + profile.sharedDataDirUri + } + + val targetPath = + if (ThemeManager.isUserTheme(selectedTheme)) { + profile.getAppUserDir() + } else { + profile.getAppShareDir() + } + + val sync = FolderSync(context, uri) + sync.copyFiles( + arrayOf("$fileNameWithoutExt.yaml", "$fileNameWithoutExt.custom.yaml"), + targetPath, + ) + } } diff --git a/app/src/main/java/com/osfans/trime/ui/setup/SetupFragment.kt b/app/src/main/java/com/osfans/trime/ui/setup/SetupFragment.kt index 0e3276ca5c..1ce57c048d 100644 --- a/app/src/main/java/com/osfans/trime/ui/setup/SetupFragment.kt +++ b/app/src/main/java/com/osfans/trime/ui/setup/SetupFragment.kt @@ -4,15 +4,27 @@ package com.osfans.trime.ui.setup +import android.app.Activity +import android.content.Context +import android.content.Intent +import android.net.Uri +import android.os.Build import android.os.Bundle +import android.provider.DocumentsContract import android.view.LayoutInflater import android.view.View import android.view.ViewGroup +import androidx.activity.result.ActivityResult +import androidx.activity.result.contract.ActivityResultContracts +import androidx.core.net.toUri import androidx.fragment.app.Fragment import androidx.fragment.app.activityViewModels +import com.osfans.trime.data.AppPrefs import com.osfans.trime.databinding.FragmentSetupBinding import com.osfans.trime.ui.setup.SetupPage.Companion.isLastPage +import com.osfans.trime.util.InputMethodUtils import com.osfans.trime.util.serializable +import timber.log.Timber class SetupFragment : Fragment() { private val viewModel: SetupViewModel by activityViewModels() @@ -30,7 +42,7 @@ class SetupFragment : Fragment() { hintText.text = page.getHintText(requireContext()) actionButton.visibility = if (new) View.GONE else View.VISIBLE actionButton.text = page.getButtonText(requireContext()) - actionButton.setOnClickListener { page.getButtonAction(requireContext()) } + actionButton.setOnClickListener { getButtonAction(page, requireContext()) } doneText.visibility = if (new) View.VISIBLE else View.GONE } field = new @@ -55,4 +67,56 @@ class SetupFragment : Fragment() { super.onResume() sync() } + + fun getButtonAction( + setupPage: SetupPage, + context: Context, + ) { + when (setupPage) { + SetupPage.Permissions -> openDirectory() + SetupPage.Enable -> InputMethodUtils.showImeEnablerActivity(context) + SetupPage.Select -> InputMethodUtils.showImePicker() + } + } + + private fun saveUri(uri: Uri) { + AppPrefs.defaultInstance().profile.userDataDirUri = uri.toString() + } + + private fun openDirectory() { + startForResult.launch(getFolderIntent()) + } + + private val startForResult = + registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result: ActivityResult -> + if (result.resultCode == Activity.RESULT_OK) { + val context = requireContext() + result.data?.data?.also { uri -> + Timber.d("Selected URI is %s", uri.toString()) + + val contentResolver = context.contentResolver + + val takeFlags: Int = + Intent.FLAG_GRANT_READ_URI_PERMISSION or + Intent.FLAG_GRANT_WRITE_URI_PERMISSION + contentResolver.takePersistableUriPermission(uri, takeFlags) + + saveUri(uri) + } + } + } + + companion object { + fun getFolderIntent(): Intent { + val pickerInitialUri = AppPrefs.Profile.URI_PREFIX + "document/primary%3Arime".toUri() + + return Intent(Intent.ACTION_OPEN_DOCUMENT_TREE).apply { + // Optionally, specify a URI for the directory that should be opened in + // the system file picker when it loads. + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + putExtra(DocumentsContract.EXTRA_INITIAL_URI, pickerInitialUri) + } + } + } + } } diff --git a/app/src/main/java/com/osfans/trime/ui/setup/SetupPage.kt b/app/src/main/java/com/osfans/trime/ui/setup/SetupPage.kt index 10bb833247..7b7b816b5b 100644 --- a/app/src/main/java/com/osfans/trime/ui/setup/SetupPage.kt +++ b/app/src/main/java/com/osfans/trime/ui/setup/SetupPage.kt @@ -6,10 +6,8 @@ package com.osfans.trime.ui.setup import android.content.Context import com.osfans.trime.R +import com.osfans.trime.data.AppPrefs import com.osfans.trime.util.InputMethodUtils -import com.osfans.trime.util.appContext -import com.osfans.trime.util.isStorageAvailable -import com.osfans.trime.util.requestExternalStoragePermission enum class SetupPage { Permissions, @@ -29,7 +27,7 @@ enum class SetupPage { fun getHintText(context: Context) = context.getText( when (this) { - Permissions -> R.string.setup__request_permmision_hint + Permissions -> R.string.setup__request_permission_hint Enable -> R.string.setup__enable_ime_hint Select -> R.string.setup__select_ime_hint }, @@ -38,23 +36,15 @@ enum class SetupPage { fun getButtonText(context: Context) = context.getText( when (this) { - Permissions -> R.string.setup__request_permmision + Permissions -> R.string.setup__request_permission Enable -> R.string.setup__enable_ime Select -> R.string.setup__select_ime }, ) - fun getButtonAction(context: Context) { - when (this) { - Permissions -> context.requestExternalStoragePermission() - Enable -> InputMethodUtils.showImeEnablerActivity(context) - Select -> InputMethodUtils.showImePicker() - } - } - fun isDone() = when (this) { - Permissions -> appContext.isStorageAvailable() + Permissions -> AppPrefs.defaultInstance().profile.isUserDataDirChosen() Enable -> InputMethodUtils.checkIsTrimeEnabled() Select -> InputMethodUtils.checkIsTrimeSelected() } diff --git a/app/src/main/java/com/osfans/trime/util/ResourceUtils.kt b/app/src/main/java/com/osfans/trime/util/ResourceUtils.kt index 40ba4fa8a0..f9ad1f2f0d 100644 --- a/app/src/main/java/com/osfans/trime/util/ResourceUtils.kt +++ b/app/src/main/java/com/osfans/trime/util/ResourceUtils.kt @@ -13,6 +13,7 @@ object ResourceUtils { dest: File, removedPrefix: String = "", ) = runCatching { + Timber.d("CopyFiles %s -> %s", filename, File(dest, filename.removePrefix(removedPrefix)).absolutePath) appContext.assets.open(filename).use { i -> File(dest, filename.removePrefix(removedPrefix)) .also { it.parentFile?.mkdirs() } @@ -34,6 +35,7 @@ object ResourceUtils { acc + copyFiles("$assetPath/$file", File(destFile, formattedDestPath), file).getOrDefault(0L) } } else { + Timber.d("CopyFiles %s -> %s", assetPath, File(destFile, formattedDestPath.split(File.pathSeparator).last()).absolutePath) appContext.assets.open(assetPath).use { i -> File(destFile, formattedDestPath.split(File.pathSeparator).last()) .also { it.parentFile?.mkdirs() } diff --git a/app/src/main/java/com/osfans/trime/util/ShortcutUtils.kt b/app/src/main/java/com/osfans/trime/util/ShortcutUtils.kt index 83a768ce1e..2b01d053a0 100644 --- a/app/src/main/java/com/osfans/trime/util/ShortcutUtils.kt +++ b/app/src/main/java/com/osfans/trime/util/ShortcutUtils.kt @@ -21,6 +21,7 @@ import com.blankj.utilcode.util.IntentUtils import com.osfans.trime.core.Rime import com.osfans.trime.daemon.RimeDaemon import com.osfans.trime.data.AppPrefs +import com.osfans.trime.data.storage.FolderExport import com.osfans.trime.ime.core.TrimeInputMethodService import com.osfans.trime.ime.symbol.SymbolBoardType import com.osfans.trime.ui.main.LiquidKeyboardEditActivity @@ -144,7 +145,16 @@ object ShortcutUtils { val prefs = AppPrefs.defaultInstance() prefs.profile.lastBackgroundSync = Date().time.toString() CoroutineScope(Dispatchers.IO).launch { - prefs.profile.lastSyncStatus = Rime.syncRimeUserData().also { RimeDaemon.restartRime() } + prefs.profile.lastSyncStatus = sync() + } + } + + suspend fun sync(fullCheck: Boolean = false): Boolean { + return if (Rime.syncRimeUserData()) { + RimeDaemon.restartRime(fullCheck) + return FolderExport.exportSyncDir() + } else { + false } } diff --git a/app/src/main/java/com/osfans/trime/util/Utils.kt b/app/src/main/java/com/osfans/trime/util/Utils.kt index 332c6165e7..b0faa59d04 100644 --- a/app/src/main/java/com/osfans/trime/util/Utils.kt +++ b/app/src/main/java/com/osfans/trime/util/Utils.kt @@ -18,11 +18,8 @@ import androidx.core.view.WindowInsetsCompat import androidx.fragment.app.Fragment import androidx.preference.Preference import androidx.recyclerview.widget.RecyclerView -import com.blankj.utilcode.util.ToastUtils -import com.hjq.permissions.OnPermissionCallback import com.hjq.permissions.Permission import com.hjq.permissions.XXPermissions -import com.osfans.trime.R import com.osfans.trime.TrimeApplication import splitties.experimental.InternalSplittiesApi import splitties.resources.withResolvedThemeAttribute @@ -112,36 +109,4 @@ inline fun Context.isStorageAvailable(): Boolean { Environment.getExternalStorageDirectory().absolutePath.isNotEmpty() } -fun Context.requestExternalStoragePermission() { - XXPermissions.with(this) - .permission(Permission.MANAGE_EXTERNAL_STORAGE) - .request( - object : OnPermissionCallback { - override fun onGranted( - permissions: List, - all: Boolean, - ) { - if (all) { - ToastUtils.showShort(R.string.external_storage_permission_granted) - } - } - - override fun onDenied( - permissions: List, - never: Boolean, - ) { - if (never) { - ToastUtils.showShort(R.string.external_storage_permission_denied) - XXPermissions.startPermissionActivity( - this@requestExternalStoragePermission, - permissions, - ) - } else { - ToastUtils.showShort(R.string.external_storage_permission_denied) - } - } - }, - ) -} - fun Configuration.isNightMode() = uiMode and Configuration.UI_MODE_NIGHT_MASK == Configuration.UI_MODE_NIGHT_YES diff --git a/app/src/main/res/layout/folder_picker_dialog.xml b/app/src/main/res/layout/folder_picker_dialog.xml index c4fc5c58fc..c2eccf072a 100644 --- a/app/src/main/res/layout/folder_picker_dialog.xml +++ b/app/src/main/res/layout/folder_picker_dialog.xml @@ -9,21 +9,21 @@ SPDX-License-Identifier: GPL-3.0-or-later - + app:layout_constraintTop_toTopOf="parent" + app:layout_constraintBottom_toBottomOf="parent"/>