Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
07ce1e9
chore(release): prepare 2.0.0-Beta.2 fork release
HKLHaoBin Sep 16, 2026
1462581
Merge remote-tracking branch 'upstream/beta' into beta
HKLHaoBin Sep 18, 2026
4e8b509
feat(android): add local migration bundle
HKLHaoBin Oct 14, 2026
68c63c1
feat(android): harden local migration restore paths
HKLHaoBin Oct 14, 2026
9b4e606
fix: satisfy Linux core lints
HKLHaoBin Oct 14, 2026
6ad7f66
fix(android): use Activity configuration callback
HKLHaoBin Oct 14, 2026
d94cf6c
feat(android): move local migration into app settings
HKLHaoBin Oct 15, 2026
5ebacf9
fix(android): recover stale local migration previews
HKLHaoBin Oct 16, 2026
6a8ca6b
debug(android): instrument local migration flow
HKLHaoBin Oct 16, 2026
89786eb
debug(android): record migration generation handoff
HKLHaoBin Oct 16, 2026
201b0ff
debug(android): classify migration stale boundary
HKLHaoBin Oct 16, 2026
43b60ce
fix(android): align local restore document revision
HKLHaoBin Oct 16, 2026
d07d645
fix(android): satisfy diagnostic lint checks
HKLHaoBin Oct 16, 2026
524067a
chore(android): remove migration diagnostics
HKLHaoBin Oct 16, 2026
4805148
Merge remote-tracking branch 'origin/beta' into feat/issue-1192-andro…
HKLHaoBin Oct 16, 2026
5b4b7e2
Merge remote-tracking branch 'upstream/beta' into feat/issue-1192-and…
HKLHaoBin Oct 16, 2026
c8cacf4
chore: format upstream beta merge
HKLHaoBin Oct 16, 2026
4c09075
fix(android): make local configuration migration transactional
H-Chris233 Oct 19, 2026
2dbc56b
merge: integrate beta Rime into Android local migration
H-Chris233 Oct 19, 2026
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
201 changes: 201 additions & 0 deletions docs/research/issue-1192.md

Large diffs are not rendered by default.

8 changes: 5 additions & 3 deletions openless-all/app/android/kotlin/ImeLearningPolicy.kt
Original file line number Diff line number Diff line change
Expand Up @@ -26,9 +26,11 @@ internal object ImeLearningPolicy {
fun migrate(context: Context): Boolean = runCatching {
val marker = context.getSharedPreferences("openless_learning_privacy", Context.MODE_PRIVATE)
if (marker.getInt("version", 0) >= PRIVACY_VERSION) return true
for (name in stores) {
if (!context.getSharedPreferences(name, Context.MODE_PRIVATE).edit().clear().commit()) return false
OpenLessMigrationState.settings {
for (name in stores) {
check(context.getSharedPreferences(name, Context.MODE_PRIVATE).edit().clear().commit())
}
check(marker.edit().putInt("version", PRIVACY_VERSION).commit())
}
marker.edit().putInt("version", PRIVACY_VERSION).commit()
}.getOrDefault(false)
}
8 changes: 5 additions & 3 deletions openless-all/app/android/kotlin/LegacyPinyinBackup.kt
Original file line number Diff line number Diff line change
Expand Up @@ -15,8 +15,10 @@ internal class LegacyPinyinBackup(context: Context) {
}.toMap()

fun importAll(entries: Map<String, String>) {
check(preferences.edit().clear().apply {
entries.forEach { (key, value) -> putString(key, value) }
}.commit()) { "Legacy pinyin backup save failed" }
check(OpenLessMigrationState.settings {
check(preferences.edit().clear().apply {
entries.forEach { (key, value) -> putString(key, value) }
}.commit()) { "Legacy pinyin backup save failed" }
}) { "migration_android_busy" }
}
}
20 changes: 17 additions & 3 deletions openless-all/app/android/kotlin/OpenLessApplication.kt
Original file line number Diff line number Diff line change
Expand Up @@ -15,9 +15,12 @@ import android.util.Log

/** Registers activity lifecycle hooks for overlay background trigger mode. */
class OpenLessApplication : Application() {
private var localMigrationStarted = false

override fun onCreate() {
super.onCreate()
OpenLessAppContext.initialize(this)
OpenLessMigrationState.attach(this)
// #region agent log
// Baseline JNI Context so overlay/dictation notifies work even when
// neither RuntimeService (IME) nor OverlayService has started yet.
Expand Down Expand Up @@ -54,6 +57,11 @@ class OpenLessApplication : Application() {
}

override fun onActivityStarted(activity: Activity) {
if (activity is OpenLessLocalMigrationActivity) {
localMigrationStarted = true
maybeHideOverlayOnForeground()
return
}
// Must use `is MainActivity` — launcher opens
// OpenLessBackendWarmupActivity (subclass), whose class name
// does NOT end with "MainActivity", so endsWith() never fired
Expand Down Expand Up @@ -93,7 +101,11 @@ class OpenLessApplication : Application() {
}

override fun onActivityStopped(activity: Activity) {
if (activity is MainActivity) {
if (activity is OpenLessLocalMigrationActivity) {
localMigrationStarted = false
return
}
if (activity is MainActivity && !localMigrationStarted) {
// #region agent log
Log.i(
"OpenLessDbg58c22b",
Expand Down Expand Up @@ -198,14 +210,15 @@ class OpenLessApplication : Application() {
return null
}
val webView = findWebView(activity.window.decorView) ?: return
val mirrorRevision = OpenLessMigrationState.mirrorRevision() ?: return
webView.evaluateJavascript(
"(function(){return localStorage.getItem('ol.locale') || document.documentElement.lang || navigator.language;})()",
) { result ->
val locale = result.trim('"').lowercase(java.util.Locale.ROOT)
if (!locale.matches(Regex("[a-z]{2,3}(-[a-z0-9]{2,8})*"))) return@evaluateJavascript
val prefs = getSharedPreferences("openless_ime_ui", MODE_PRIVATE)
if (prefs.getString("locale", null) != locale) {
prefs.edit().putString("locale", locale).apply()
OpenLessMigrationState.mirror(mirrorRevision) { prefs.edit().putString("locale", locale).apply() }
}
}
}
Expand All @@ -231,6 +244,7 @@ class OpenLessApplication : Application() {
return null
}
val webView = findWebView(activity.window.decorView) ?: return
val mirrorRevision = OpenLessMigrationState.mirrorRevision() ?: return
webView.evaluateJavascript(
"""
(function(){
Expand All @@ -250,7 +264,7 @@ class OpenLessApplication : Application() {
if (theme != "dark" && theme != "light") return@evaluateJavascript
val prefs = getSharedPreferences("openless_ime_ui", MODE_PRIVATE)
if (prefs.getString("theme_mode", null) != theme) {
prefs.edit().putString("theme_mode", theme).apply()
OpenLessMigrationState.mirror(mirrorRevision) { prefs.edit().putString("theme_mode", theme).apply() }
}
}
}
Expand Down
54 changes: 44 additions & 10 deletions openless-all/app/android/kotlin/OpenLessContentReader.kt
Original file line number Diff line number Diff line change
Expand Up @@ -8,30 +8,64 @@ import java.io.ByteArrayOutputStream
import java.io.IOException
import java.io.InputStream

/** Reads a bounded document selected through Android's Storage Access Framework. */
@Keep
sealed class OpenLessReadResult {
data class Success(val bytes: ByteArray) : OpenLessReadResult()
data class Failure(val reason: Reason) : OpenLessReadResult()

enum class Reason {
INVALID_URI,
ACCESS_DENIED,
TOO_LARGE,
READ_FAILED,
CANCELLED,
}
}

/** Reads a bounded document and preserves failure classes for migration UI. */
@Keep
object OpenLessContentReader {
private const val TAG = "OpenLessContentReader"
private const val BUFFER_BYTES = 8 * 1024

@Keep
@JvmStatic
fun readBytes(context: Context, uriString: String, maxBytes: Int): ByteArray? {
fun readStrict(context: Context, uriString: String, maxBytes: Int): OpenLessReadResult {
if (maxBytes < 0) return OpenLessReadResult.Failure(OpenLessReadResult.Reason.TOO_LARGE)
val uri = runCatching { Uri.parse(uriString) }.getOrNull()
?: return OpenLessReadResult.Failure(OpenLessReadResult.Reason.INVALID_URI)
if (uri.scheme != "content") {
return OpenLessReadResult.Failure(OpenLessReadResult.Reason.INVALID_URI)
}
return try {
val uri = Uri.parse(uriString)
context.contentResolver.openInputStream(uri)?.use { input ->
readBounded(input, maxBytes)
val bytes = context.contentResolver.openInputStream(uri)?.use { input -> readBounded(input, maxBytes) }
?: return OpenLessReadResult.Failure(OpenLessReadResult.Reason.READ_FAILED)
OpenLessReadResult.Success(bytes)
} catch (_: SecurityException) {
OpenLessReadResult.Failure(OpenLessReadResult.Reason.ACCESS_DENIED)
} catch (_: java.util.concurrent.CancellationException) {
OpenLessReadResult.Failure(OpenLessReadResult.Reason.CANCELLED)
} catch (error: IOException) {
if (error.message?.contains("exceeds", ignoreCase = true) == true) {
OpenLessReadResult.Failure(OpenLessReadResult.Reason.TOO_LARGE)
} else {
Log.e(TAG, "failed to read selected document", error)
OpenLessReadResult.Failure(OpenLessReadResult.Reason.READ_FAILED)
}
?: run {
Log.w(TAG, "openInputStream returned null for selected document")
null
}
} catch (error: Throwable) {
Log.e(TAG, "failed to read selected document", error)
null
OpenLessReadResult.Failure(OpenLessReadResult.Reason.READ_FAILED)
}
}

@Keep
@JvmStatic
fun readBytes(context: Context, uriString: String, maxBytes: Int): ByteArray? =
when (val result = readStrict(context, uriString, maxBytes)) {
is OpenLessReadResult.Success -> result.bytes
is OpenLessReadResult.Failure -> null
}

internal fun readBounded(input: InputStream, maxBytes: Int): ByteArray {
require(maxBytes >= 0) { "maxBytes must not be negative" }
val output = ByteArrayOutputStream(minOf(maxBytes, BUFFER_BYTES))
Expand Down
68 changes: 49 additions & 19 deletions openless-all/app/android/kotlin/OpenLessContentWriter.kt
Original file line number Diff line number Diff line change
Expand Up @@ -11,37 +11,67 @@ import androidx.annotation.Keep
import java.io.File
import java.io.IOException

/**
* Writes bytes to a SAF content:// URI via ContentResolver.
*
* Prefer this over tauri-plugin-fs for exports: fs detaches the FD early and some providers
* finalize a 0-byte file before Rust finishes writing.
*/
@Keep
sealed class OpenLessWriteResult {
data object Success : OpenLessWriteResult()
data class Failure(val reason: Reason) : OpenLessWriteResult()

enum class Reason {
INVALID_URI,
ACCESS_DENIED,
SHORT_WRITE,
FLUSH_FAILED,
WRITE_FAILED,
CANCELLED,
}
}

/** Writes bounded bytes to a user-selected SAF document. */
@Keep
object OpenLessContentWriter {
private const val TAG = "OpenLessContentWriter"

@Keep
@JvmStatic
fun writeBytes(context: Context, uriString: String, bytes: ByteArray): Boolean {
fun writeStrict(context: Context, uriString: String, bytes: ByteArray): OpenLessWriteResult {
val uri = runCatching { Uri.parse(uriString) }.getOrNull()
?: return OpenLessWriteResult.Failure(OpenLessWriteResult.Reason.INVALID_URI)
if (uri.scheme != "content") {
return OpenLessWriteResult.Failure(OpenLessWriteResult.Reason.INVALID_URI)
}
return try {
val uri = Uri.parse(uriString)
context.contentResolver.openOutputStream(uri)?.use { output ->
output.write(bytes)
output.flush()
}
?: run {
Log.w(TAG, "openOutputStream returned null for selected document")
return false
val output = context.contentResolver.openOutputStream(uri)
?: return OpenLessWriteResult.Failure(OpenLessWriteResult.Reason.WRITE_FAILED)
var written = 0
output.use { stream ->
stream.write(bytes)
written = bytes.size
try {
stream.flush()
} catch (_: Throwable) {
return OpenLessWriteResult.Failure(OpenLessWriteResult.Reason.FLUSH_FAILED)
}
Log.i(TAG, "wrote ${bytes.size} bytes to selected document")
true
}
if (written != bytes.size) {
OpenLessWriteResult.Failure(OpenLessWriteResult.Reason.SHORT_WRITE)
} else {
OpenLessWriteResult.Success
}
} catch (_: SecurityException) {
OpenLessWriteResult.Failure(OpenLessWriteResult.Reason.ACCESS_DENIED)
} catch (_: java.util.concurrent.CancellationException) {
OpenLessWriteResult.Failure(OpenLessWriteResult.Reason.CANCELLED)
} catch (error: Throwable) {
Log.e(TAG, "failed to write selected document", error)
false
OpenLessWriteResult.Failure(OpenLessWriteResult.Reason.WRITE_FAILED)
}
}

@Keep
@JvmStatic
fun writeBytes(context: Context, uriString: String, bytes: ByteArray): Boolean =
writeStrict(context, uriString, bytes) is OpenLessWriteResult.Success

/**
* Writes an app-generated export into Downloads without a SAF picker.
*
Expand Down Expand Up @@ -103,4 +133,4 @@ object OpenLessContentWriter {
Log.i(TAG, "wrote ${bytes.size} bytes to app Downloads (pre-Q): $appFile")
return appFile.absolutePath
}
}
}
Loading
Loading