Release IKAR 1.0.7: port navigation and sharing improvements, harden calls
Tests / test (push) Canceled after 0s
Build Android (FCM) / build-android-fcm (push) Canceled after 0s
Build Android / build-android (push) Canceled after 0s
Build iOS / build-ios (push) Canceled after 0s
Build Linux / build-linux (push) Canceled after 0s
Build macOS / build-macos (push) Canceled after 0s
Build Windows / build-windows (push) Canceled after 0s
Native crypto / native-crypto (push) Canceled after 0s
Release (main) / android (oneme) (push) Canceled after 0s
Release (main) / android (qlyra) (push) Canceled after 0s
Release (main) / windows (push) Canceled after 0s
Release (main) / linux (push) Canceled after 0s
Release (main) / macos (push) Canceled after 0s
Release (main) / ios (push) Canceled after 0s
Release (main) / release (push) Canceled after 0s

This commit is contained in:
sevenhill
2026-10-08 20:38:54 +03:00
parent 68f7957043
commit 317cfdae38
37 changed files with 1483 additions and 282 deletions
+1
View File
@@ -1,5 +1,6 @@
gradle-wrapper.jar
/.gradle
/.kotlin/
/captures/
/gradlew
/gradlew.bat
@@ -77,8 +77,6 @@ class MainActivity : AudioServiceFragmentActivity() {
private var pendingCall: Map<String, Any?>? = null
private var pendingChat: Long = 0L
private var pendingShare: Map<String, Any?>? = null
private var pendingShareTask: java.util.concurrent.Future<Map<String, Any?>?>? = null
private val shareExecutor = java.util.concurrent.Executors.newSingleThreadExecutor()
private val shareHandler = Handler(Looper.getMainLooper())
@@ -507,26 +505,25 @@ class MainActivity : AudioServiceFragmentActivity() {
"ru.ikar.app/share",
).setMethodCallHandler { call, result ->
when (call.method) {
"consumeInitialShare" -> {
"consumeInitialShare", "acknowledgeShare", "releaseShare" -> {
stashShare(intent, emit = false)
val ready = pendingShare
val task = pendingShareTask
if (ready != null) {
pendingShare = null
result.success(ready)
} else if (task != null) {
pendingShareTask = null
shareExecutor.execute {
val payload = try {
task.get()
} catch (e: Exception) {
Log.w(SHARE_TAG, "materialize failed: $e")
null
shareExecutor.execute {
try {
val value = when (call.method) {
"consumeInitialShare" -> ShareQueue.consume(applicationContext)
"acknowledgeShare" -> {
ShareQueue.acknowledge(applicationContext, call.argument<String>("id")!!)
null
}
else -> {
ShareQueue.release(applicationContext, call.argument<String>("id")!!)
null
}
}
shareHandler.post { result.success(payload) }
shareHandler.post { result.success(value) }
} catch (e: Exception) {
shareHandler.post { result.error("SHARE_QUEUE", e.message, null) }
}
} else {
result.success(null)
}
}
"clearCache" -> {
@@ -658,23 +655,14 @@ class MainActivity : AudioServiceFragmentActivity() {
intent.action = Intent.ACTION_MAIN
intent.removeExtra(Intent.EXTRA_STREAM)
intent.removeExtra(Intent.EXTRA_TEXT)
val task = shareExecutor.submit<Map<String, Any?>?> {
ShareIntake.materialize(applicationContext, snapshot)
}
if (!emit) {
pendingShareTask = task
return
}
shareExecutor.execute {
val payload = try {
task.get()
try {
val payload = ShareIntake.materialize(applicationContext, snapshot)
?: return@execute
ShareQueue.enqueue(applicationContext, payload)
if (emit) shareHandler.post { ShareIntake.sink?.success(true) }
} catch (e: Exception) {
Log.w(SHARE_TAG, "materialize failed: $e")
null
} ?: return@execute
shareHandler.post {
val sink = ShareIntake.sink
if (sink != null) sink.success(payload) else pendingShare = payload
Log.w(SHARE_TAG, "cannot persist share: $e")
}
}
}
@@ -60,7 +60,7 @@ object ShareIntake {
fun clearCache(context: Context, paths: List<String>) {
try {
val dir = File(context.cacheDir, CACHE_DIR).canonicalFile
val dir = File(context.filesDir, CACHE_DIR).canonicalFile
if (!dir.isDirectory) return
for (path in paths) {
val file = File(path).canonicalFile
@@ -96,7 +96,7 @@ object ShareIntake {
val displayName = queryDisplayName(resolver, uri) ?: fallbackName(uri, mime)
return try {
val dir = File(context.cacheDir, CACHE_DIR).apply { mkdirs() }
val dir = File(context.filesDir, CACHE_DIR).apply { mkdirs() }
val target = File(dir, "${System.currentTimeMillis()}_${seq.incrementAndGet()}_${sanitize(displayName)}")
resolver.openInputStream(uri).use { input ->
if (input == null) return null
@@ -0,0 +1,67 @@
package ru.ikar.app
import android.content.Context
import org.json.JSONObject
import java.io.File
import java.util.UUID
/** Durable intake; all methods are called on MainActivity's share executor. */
object ShareQueue {
private fun directory(context: Context) = File(context.filesDir, "share_queue").apply { mkdirs() }
private fun record(context: Context, id: String): File {
require(id.matches(Regex("[0-9a-f-]{36}")))
return File(directory(context), "$id.json")
}
fun enqueue(context: Context, payload: Map<String, Any?>) {
val id = UUID.randomUUID().toString()
val target = record(context, id)
val json = JSONObject(payload).put("id", id).put("receivedAt", System.currentTimeMillis())
val staged = File(target.path + ".next")
staged.writeText(json.toString())
check(staged.renameTo(target)) { "Cannot persist shared content" }
}
fun consume(context: Context): Map<String, Any?>? {
val entries = directory(context).listFiles()?.filter { it.extension == "json" }
?.sortedBy { it.lastModified() } ?: return null
for (entry in entries) {
val json = try { JSONObject(entry.readText()) } catch (_: Exception) { continue }
if (json.optBoolean("acknowledged")) {
release(context, json.getString("id"))
continue
}
return decode(json)
}
return null
}
fun acknowledge(context: Context, id: String) {
val target = record(context, id)
if (!target.exists()) return
val json = JSONObject(target.readText()).put("acknowledged", true)
val staged = File(target.path + ".next")
staged.writeText(json.toString())
check(staged.renameTo(target))
}
fun release(context: Context, id: String) {
val target = record(context, id)
if (!target.exists()) return
val json = JSONObject(target.readText())
val files = json.optJSONArray("files")
val paths = (0 until (files?.length() ?: 0)).mapNotNull {
files?.optJSONObject(it)?.optString("path")
}
ShareIntake.clearCache(context, paths)
check(target.delete())
}
private fun decode(json: JSONObject): Map<String, Any?> = json.keys().asSequence().associateWith { key ->
when (val value = json.get(key)) {
JSONObject.NULL -> null
is org.json.JSONArray -> (0 until value.length()).map { decode(value.getJSONObject(it)) }
else -> value
}
}
}