feat: комета в 'поделится
'
This commit is contained in:
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user