feat: комета в 'поделится

'
This commit is contained in:
Jganenokk
2026-08-22 23:18:09 +07:00
parent 0091b5f272
commit 5d115e0f9e
15 changed files with 1972 additions and 9 deletions
+17
View File
@@ -85,6 +85,23 @@
<data android:scheme="komet"/>
<data android:scheme="max"/>
</intent-filter>
<intent-filter android:label="@string/share_target_label">
<action android:name="android.intent.action.SEND"/>
<category android:name="android.intent.category.DEFAULT"/>
<data android:mimeType="text/*"/>
<data android:mimeType="image/*"/>
<data android:mimeType="video/*"/>
<data android:mimeType="audio/*"/>
<data android:mimeType="application/*"/>
</intent-filter>
<intent-filter android:label="@string/share_target_label">
<action android:name="android.intent.action.SEND_MULTIPLE"/>
<category android:name="android.intent.category.DEFAULT"/>
<data android:mimeType="image/*"/>
<data android:mimeType="video/*"/>
<data android:mimeType="audio/*"/>
<data android:mimeType="application/*"/>
</intent-filter>
</activity>
<activity-alias
android:name="ru.komet.app.MinimalIcon"
@@ -77,9 +77,14 @@ class MainActivity : FlutterActivity() {
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())
private companion object {
const val LOG_TAG = "VpnBypass"
const val SHARE_TAG = "ShareIntake"
const val NFC_TAG = "NfcExchange"
const val KEEP_ENGINE_ID = "komet_keep_engine"
const val NFC_PHASE_MIN_MS = 350L
@@ -439,6 +444,54 @@ class MainActivity : FlutterActivity() {
}
})
MethodChannel(
flutterEngine.dartExecutor.binaryMessenger,
"ru.komet.app/share",
).setMethodCallHandler { call, result ->
when (call.method) {
"consumeInitialShare" -> {
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
}
shareHandler.post { result.success(payload) }
}
} else {
result.success(null)
}
}
"clearCache" -> {
ShareIntake.clearCache(applicationContext)
result.success(null)
}
else -> result.notImplemented()
}
}
EventChannel(
flutterEngine.dartExecutor.binaryMessenger,
"ru.komet.app/share_events",
).setStreamHandler(object : EventChannel.StreamHandler {
override fun onListen(arguments: Any?, events: EventChannel.EventSink?) {
ShareIntake.sink = events
}
override fun onCancel(arguments: Any?) {
ShareIntake.sink = null
}
})
FkmChannel.attach(flutterEngine, this)
}
@@ -447,6 +500,7 @@ class MainActivity : FlutterActivity() {
super.onCreate(savedInstanceState)
intent?.let { if (it.hasExtra(CallConst.EXTRA_CALL)) stashCall(it, emit = false) }
stashChatOpen(intent, emit = false)
stashShare(intent, emit = false)
}
override fun onNewIntent(intent: Intent) {
@@ -457,6 +511,7 @@ class MainActivity : FlutterActivity() {
stashCall(intent, emit = true)
}
stashChatOpen(intent, emit = true)
stashShare(intent, emit = true)
}
private fun stashChatOpen(source: Intent?, emit: Boolean) {
@@ -471,6 +526,34 @@ class MainActivity : FlutterActivity() {
}
}
private fun stashShare(source: Intent?, emit: Boolean) {
if (!ShareIntake.isShare(source)) return
val intent = source ?: return
val snapshot = ShareIntake.snapshot(intent) ?: return
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()
} 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
}
}
}
private fun stashCall(intent: Intent, emit: Boolean) {
val json = intent.getStringExtra(CallConst.EXTRA_CALL) ?: return
val action = intent.getStringExtra(CallConst.EXTRA_ACTION) ?: CallConst.ACTION_RING
@@ -845,6 +928,7 @@ class MainActivity : FlutterActivity() {
}
override fun onDestroy() {
shareExecutor.shutdown()
if (keepEngineAlive() && isFinishing) {
Log.d("KometFcm", "task removed, caching engine (call=${CallState.inCall} fkm=${FkmState.enabled})")
flutterEngine?.let { FlutterEngineCache.getInstance().put(KEEP_ENGINE_ID, it) }
@@ -0,0 +1,154 @@
package ru.komet.app
import android.content.ContentResolver
import android.content.Context
import android.content.Intent
import android.net.Uri
import android.provider.OpenableColumns
import android.util.Log
import android.webkit.MimeTypeMap
import io.flutter.plugin.common.EventChannel
import java.io.File
import java.util.concurrent.atomic.AtomicLong
object ShareIntake {
private const val TAG = "ShareIntake"
private const val CACHE_DIR = "shared_in"
private const val MAX_FILES = 30
private val seq = AtomicLong(0L)
@Volatile
var sink: EventChannel.EventSink? = null
fun isShare(intent: Intent?): Boolean {
val action = intent?.action ?: return false
return action == Intent.ACTION_SEND || action == Intent.ACTION_SEND_MULTIPLE
}
class Snapshot(
val uris: List<Uri>,
val text: String?,
val subject: String?,
val intentType: String?,
)
fun snapshot(intent: Intent): Snapshot? {
val uris = collectUris(intent).take(MAX_FILES)
val text = intent.getCharSequenceExtra(Intent.EXTRA_TEXT)?.toString()
val subject = intent.getStringExtra(Intent.EXTRA_SUBJECT)
if (uris.isEmpty() && text.isNullOrBlank()) return null
return Snapshot(uris, text, subject, intent.type)
}
fun materialize(context: Context, snapshot: Snapshot): Map<String, Any?>? {
val files = ArrayList<Map<String, Any?>>()
for (uri in snapshot.uris) {
val copied = copyToCache(context, uri, snapshot.intentType)
if (copied != null) files.add(copied)
}
if (files.isEmpty() && snapshot.text.isNullOrBlank()) return null
return mapOf(
"files" to files,
"text" to snapshot.text,
"subject" to snapshot.subject,
)
}
fun clearCache(context: Context) {
try {
val dir = File(context.cacheDir, CACHE_DIR)
if (!dir.isDirectory) return
dir.listFiles()?.forEach { it.delete() }
} catch (e: Exception) {
Log.w(TAG, "cache cleanup failed: $e")
}
}
private fun collectUris(intent: Intent): List<Uri> {
if (intent.action == Intent.ACTION_SEND_MULTIPLE) {
val list = if (android.os.Build.VERSION.SDK_INT >= 33) {
intent.getParcelableArrayListExtra(Intent.EXTRA_STREAM, Uri::class.java)
} else {
@Suppress("DEPRECATION")
intent.getParcelableArrayListExtra<Uri>(Intent.EXTRA_STREAM)
}
return list?.filterNotNull() ?: emptyList()
}
val single = if (android.os.Build.VERSION.SDK_INT >= 33) {
intent.getParcelableExtra(Intent.EXTRA_STREAM, Uri::class.java)
} else {
@Suppress("DEPRECATION")
intent.getParcelableExtra<Uri>(Intent.EXTRA_STREAM)
}
return if (single != null) listOf(single) else emptyList()
}
private fun copyToCache(context: Context, uri: Uri, intentType: String?): Map<String, Any?>? {
val resolver = context.contentResolver
val mime = resolveMime(resolver, uri, intentType)
val displayName = queryDisplayName(resolver, uri) ?: fallbackName(uri, mime)
return try {
val dir = File(context.cacheDir, CACHE_DIR).apply { mkdirs() }
val target = File(dir, "${System.currentTimeMillis()}_${seq.incrementAndGet()}_${sanitize(displayName)}")
resolver.openInputStream(uri).use { input ->
if (input == null) return null
target.outputStream().use { output -> input.copyTo(output) }
}
if (target.length() <= 0L) {
target.delete()
return null
}
mapOf(
"path" to target.absolutePath,
"name" to displayName,
"mime" to mime,
"size" to target.length(),
)
} catch (e: Exception) {
Log.w(TAG, "cannot read $uri: $e")
null
}
}
private fun resolveMime(resolver: ContentResolver, uri: Uri, intentType: String?): String {
val fromResolver = resolver.getType(uri)
if (!fromResolver.isNullOrBlank() && fromResolver != "*/*") return fromResolver
val ext = MimeTypeMap.getFileExtensionFromUrl(uri.toString())
if (!ext.isNullOrBlank()) {
val guessed = MimeTypeMap.getSingleton().getMimeTypeFromExtension(ext.lowercase())
if (!guessed.isNullOrBlank()) return guessed
}
if (!intentType.isNullOrBlank() && intentType != "*/*") return intentType
return "application/octet-stream"
}
private fun queryDisplayName(resolver: ContentResolver, uri: Uri): String? {
if (uri.scheme == ContentResolver.SCHEME_FILE) return uri.lastPathSegment
return try {
resolver.query(uri, arrayOf(OpenableColumns.DISPLAY_NAME), null, null, null)?.use { cursor ->
val index = cursor.getColumnIndex(OpenableColumns.DISPLAY_NAME)
if (index >= 0 && cursor.moveToFirst()) cursor.getString(index) else null
}
} catch (e: Exception) {
Log.w(TAG, "display name for $uri: $e")
null
}
}
private fun fallbackName(uri: Uri, mime: String): String {
val last = uri.lastPathSegment?.substringAfterLast('/')
if (!last.isNullOrBlank() && last.contains('.')) return last
val ext = MimeTypeMap.getSingleton().getExtensionFromMimeType(mime) ?: "bin"
return "shared_${System.currentTimeMillis()}.$ext"
}
private fun sanitize(name: String): String {
val cleaned = name.replace(Regex("[^A-Za-z0-9._-]"), "_")
return if (cleaned.length <= 64) cleaned else cleaned.takeLast(64)
}
}
@@ -9,4 +9,5 @@
<string name="fkm_explain">Это уведомление держит фоновое соединение с сервером, чтобы сообщения приходили без гугловых пушей. Убрать его можно, выключив FKM — кнопкой ниже или в Настройки → Уведомления → FKM.</string>
<string name="fkm_disable">Выключить</string>
<string name="notif_deleted_prefix">Удалено:</string>
<string name="share_target_label">Отправить в чат</string>
</resources>
@@ -11,4 +11,5 @@
<string name="fkm_explain">This notification is what keeps a background connection to the server, so messages arrive without Google push. To get rid of it, turn FKM off — with the button below, or in Settings → Notifications → FKM.</string>
<string name="fkm_disable">Turn off</string>
<string name="notif_deleted_prefix">Deleted:</string>
<string name="share_target_label">Send to a chat</string>
</resources>