wip: Sicherungs-Commit aller Änderungen seit April + Arbeitsstruktur (CLAUDE.md, ROADMAP.md, PROGRESS.md, Autopilot)
6 Wochen uncommittete Arbeit (Voice-Channels, LiveKit-Manager, Settings-Modal u.v.m.) als ein WIP-Commit gesichert, damit nichts verloren geht und der Pi den aktuellen Stand klonen kann. Thematische Aufarbeitung: siehe ROADMAP M0. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CPrAGBxBT6GfPXzeWQ4AXb
@@ -1,8 +1,19 @@
|
||||
import java.util.Properties
|
||||
|
||||
plugins {
|
||||
id("com.android.application")
|
||||
id("kotlin-android")
|
||||
// The Flutter Gradle Plugin must be applied after the Android and Kotlin Gradle plugins.
|
||||
id("dev.flutter.flutter-gradle-plugin")
|
||||
id("com.google.gms.google-services")
|
||||
}
|
||||
|
||||
// Release signing is configured via android/key.properties (gitignored).
|
||||
// The keystore lives outside the repo; see key.properties for the path.
|
||||
val keystoreProperties = Properties()
|
||||
val keystorePropertiesFile = rootProject.file("key.properties")
|
||||
if (keystorePropertiesFile.exists()) {
|
||||
keystorePropertiesFile.inputStream().use { keystoreProperties.load(it) }
|
||||
}
|
||||
|
||||
android {
|
||||
@@ -11,6 +22,7 @@ android {
|
||||
ndkVersion = flutter.ndkVersion
|
||||
|
||||
compileOptions {
|
||||
isCoreLibraryDesugaringEnabled = true
|
||||
sourceCompatibility = JavaVersion.VERSION_17
|
||||
targetCompatibility = JavaVersion.VERSION_17
|
||||
}
|
||||
@@ -30,13 +42,42 @@ android {
|
||||
versionName = flutter.versionName
|
||||
}
|
||||
|
||||
buildTypes {
|
||||
release {
|
||||
// TODO: Add your own signing config for the release build.
|
||||
// Signing with the debug keys for now, so `flutter run --release` works.
|
||||
signingConfig = signingConfigs.getByName("debug")
|
||||
signingConfigs {
|
||||
if (keystorePropertiesFile.exists()) {
|
||||
create("release") {
|
||||
keyAlias = keystoreProperties["keyAlias"] as String
|
||||
keyPassword = keystoreProperties["keyPassword"] as String
|
||||
storeFile = file(keystoreProperties["storeFile"] as String)
|
||||
storePassword = keystoreProperties["storePassword"] as String
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
buildTypes {
|
||||
release {
|
||||
// Falls back to debug signing on machines without key.properties
|
||||
// so `flutter run --release` still works there.
|
||||
signingConfig = if (keystorePropertiesFile.exists()) {
|
||||
signingConfigs.getByName("release")
|
||||
} else {
|
||||
signingConfigs.getByName("debug")
|
||||
}
|
||||
isMinifyEnabled = true
|
||||
isShrinkResources = true
|
||||
proguardFiles(
|
||||
getDefaultProguardFile("proguard-android-optimize.txt"),
|
||||
"proguard-rules.pro",
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
dependencies {
|
||||
coreLibraryDesugaring("com.android.tools:desugar_jdk_libs:2.1.4")
|
||||
// Required to compile PushService.kt which extends FirebaseMessagingService directly.
|
||||
// Version must stay in sync with FirebaseSDKVersion in firebase_core's gradle.properties.
|
||||
implementation(platform("com.google.firebase:firebase-bom:33.16.0"))
|
||||
implementation("com.google.firebase:firebase-messaging-ktx")
|
||||
}
|
||||
|
||||
flutter {
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
{
|
||||
"project_info": {
|
||||
"project_number": "858748942228",
|
||||
"project_id": "chat-pyramid",
|
||||
"storage_bucket": "chat-pyramid.firebasestorage.app"
|
||||
},
|
||||
"client": [
|
||||
{
|
||||
"client_info": {
|
||||
"mobilesdk_app_id": "1:858748942228:android:661405508fbb2f40d30181",
|
||||
"android_client_info": {
|
||||
"package_name": "chat.pyramid.pyramid"
|
||||
}
|
||||
},
|
||||
"oauth_client": [],
|
||||
"api_key": [
|
||||
{
|
||||
"current_key": "AIzaSyDbqLHYFHqUdPOAexDFTpMvQVkQHY6chFE"
|
||||
}
|
||||
],
|
||||
"services": {
|
||||
"appinvite_service": {
|
||||
"other_platform_oauth_client": []
|
||||
}
|
||||
}
|
||||
}
|
||||
],
|
||||
"configuration_version": "1"
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
# Flutter's own keep rules are added automatically by the Flutter Gradle plugin.
|
||||
|
||||
# WebRTC / LiveKit: native code calls Java classes via JNI by name,
|
||||
# so nothing under org.webrtc may be stripped or renamed.
|
||||
-keep class org.webrtc.** { *; }
|
||||
-dontwarn org.webrtc.**
|
||||
-keep class livekit.org.webrtc.** { *; }
|
||||
-dontwarn livekit.org.webrtc.**
|
||||
|
||||
# flutter_local_notifications deserializes scheduled notifications with Gson
|
||||
# using reflection; stripped type adapters crash on the next reboot/schedule.
|
||||
-keep class com.dexterous.flutterlocalnotifications.** { *; }
|
||||
-keepattributes Signature
|
||||
-keepattributes *Annotation*
|
||||
|
||||
# Flutter references Play Core for deferred components, which this app
|
||||
# doesn't ship — silence the missing-class errors from R8.
|
||||
-dontwarn com.google.android.play.core.**
|
||||
@@ -1,4 +1,6 @@
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:tools="http://schemas.android.com/tools">
|
||||
|
||||
<uses-permission android:name="android.permission.INTERNET"/>
|
||||
<uses-permission android:name="android.permission.RECORD_AUDIO"/>
|
||||
<uses-permission android:name="android.permission.CAMERA"/>
|
||||
@@ -7,8 +9,13 @@
|
||||
<uses-permission android:name="android.permission.BLUETOOTH_CONNECT"/>
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MEDIA_PROJECTION" />
|
||||
<uses-permission android:name="android.permission.POST_NOTIFICATIONS"/>
|
||||
<uses-permission android:name="android.permission.VIBRATE"/>
|
||||
<uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES"/>
|
||||
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" android:maxSdkVersion="28"/>
|
||||
<uses-permission android:name="android.permission.WAKE_LOCK"/>
|
||||
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED"/>
|
||||
<uses-permission android:name="android.permission.REQUEST_IGNORE_BATTERY_OPTIMIZATIONS"/>
|
||||
|
||||
<application
|
||||
android:label="pyramid"
|
||||
@@ -40,6 +47,28 @@
|
||||
<action android:name="android.intent.action.MAIN"/>
|
||||
<category android:name="android.intent.category.LAUNCHER"/>
|
||||
</intent-filter>
|
||||
<!-- Share-Target: "Teilen nach Pyramid" aus anderen Apps -->
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.SEND"/>
|
||||
<category android:name="android.intent.category.DEFAULT"/>
|
||||
<data android:mimeType="text/plain"/>
|
||||
</intent-filter>
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.SEND"/>
|
||||
<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>
|
||||
<intent-filter>
|
||||
<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>
|
||||
<!-- FileProvider for APK installs (update downloader) -->
|
||||
<provider
|
||||
@@ -51,6 +80,42 @@
|
||||
android:name="android.support.FILE_PROVIDER_PATHS"
|
||||
android:resource="@xml/file_paths"/>
|
||||
</provider>
|
||||
<!-- FCM: default channel + icon so system-generated notifications work -->
|
||||
<meta-data
|
||||
android:name="com.google.firebase.messaging.default_notification_channel_id"
|
||||
android:value="pyramid_messages"/>
|
||||
<meta-data
|
||||
android:name="com.google.firebase.messaging.default_notification_icon"
|
||||
android:value="@drawable/ic_notification"/>
|
||||
<!-- Disable the Flutter Firebase plugin's FCM service — PushService.kt
|
||||
handles FCM directly in native Kotlin, which is far more reliable on
|
||||
Samsung/OEM devices where the Dart VM start is blocked by battery
|
||||
optimisation when the app is killed. -->
|
||||
<service
|
||||
android:name="io.flutter.plugins.firebase.messaging.FlutterFirebaseMessagingService"
|
||||
tools:node="remove"/>
|
||||
<!-- Also remove the legacy C2DM broadcast receiver so Samsung cannot
|
||||
start a second Dart background isolate via the old GCM/C2DM path. -->
|
||||
<receiver
|
||||
android:name="io.flutter.plugins.firebase.messaging.FlutterFirebaseMessagingReceiver"
|
||||
tools:node="remove"/>
|
||||
<!-- Native FCM handler — receives messages even when the app is killed. -->
|
||||
<service
|
||||
android:name=".PushService"
|
||||
android:exported="false">
|
||||
<intent-filter>
|
||||
<action android:name="com.google.firebase.MESSAGING_EVENT"/>
|
||||
</intent-filter>
|
||||
</service>
|
||||
<!-- Handles inline reply and dismiss actions without opening an Activity. -->
|
||||
<receiver
|
||||
android:name=".ReplyReceiver"
|
||||
android:exported="true">
|
||||
<intent-filter>
|
||||
<action android:name="chat.pyramid.pyramid.REPLY"/>
|
||||
<action android:name="chat.pyramid.pyramid.DISMISS"/>
|
||||
</intent-filter>
|
||||
</receiver>
|
||||
<!-- Don't delete the meta-data below.
|
||||
This is used by the Flutter tool to generate GeneratedPluginRegistrant.java -->
|
||||
<meta-data
|
||||
@@ -63,6 +128,15 @@
|
||||
|
||||
In particular, this is used by the Flutter engine in io.flutter.plugin.text.ProcessTextPlugin. -->
|
||||
<queries>
|
||||
<!-- url_launcher: required on Android 11+ for canLaunchUrl to work -->
|
||||
<intent>
|
||||
<action android:name="android.intent.action.VIEW"/>
|
||||
<data android:scheme="https"/>
|
||||
</intent>
|
||||
<intent>
|
||||
<action android:name="android.intent.action.VIEW"/>
|
||||
<data android:scheme="http"/>
|
||||
</intent>
|
||||
<intent>
|
||||
<action android:name="android.intent.action.PROCESS_TEXT"/>
|
||||
<data android:mimeType="text/plain"/>
|
||||
|
||||
@@ -0,0 +1,140 @@
|
||||
package chat.pyramid.pyramid
|
||||
|
||||
import android.content.Context
|
||||
import android.os.Handler
|
||||
import android.os.Looper
|
||||
import android.util.Log
|
||||
import io.flutter.FlutterInjector
|
||||
import io.flutter.embedding.engine.FlutterEngine
|
||||
import io.flutter.embedding.engine.dart.DartExecutor
|
||||
import io.flutter.plugin.common.MethodChannel
|
||||
import io.flutter.view.FlutterCallbackInformation
|
||||
|
||||
/**
|
||||
* Bootstraps a headless Flutter background engine running the
|
||||
* `notificationEngineMain` Dart entrypoint and forwards work items
|
||||
* (decrypt-and-show, send-reply) to it via a MethodChannel.
|
||||
*
|
||||
* Only used when the app is KILLED (no main FlutterEngine). The Dart side builds
|
||||
* a short-lived Matrix client against the same DB to decrypt / send. This is safe
|
||||
* because callers gate on the main app being dead — never two clients at once.
|
||||
*/
|
||||
object BgEngine {
|
||||
private const val TAG = "PYRAMID-BGENGINE"
|
||||
private const val CHANNEL = "chat.pyramid.pyramid/bg_engine"
|
||||
|
||||
private var engine: FlutterEngine? = null
|
||||
private var channel: MethodChannel? = null
|
||||
private var ready = false
|
||||
private var appContext: Context? = null
|
||||
// Tasks queued until the Dart handler signals readiness.
|
||||
private val pending = mutableListOf<Pair<String, Map<String, Any?>>>()
|
||||
// Pending "Neue Nachricht" fallback placeholders, keyed by notifId. Shown
|
||||
// only if decryption doesn't replace them in time; cancelled by showDecrypted.
|
||||
private val fallbackHandler = Handler(Looper.getMainLooper())
|
||||
private val fallbacks = mutableMapOf<Int, Runnable>()
|
||||
|
||||
/** Schedule a placeholder shown after [delayMs] unless decryption beats it. */
|
||||
fun scheduleFallback(context: Context, title: String, roomId: String, notifId: Int, delayMs: Long) {
|
||||
val ctx = context.applicationContext
|
||||
appContext = ctx
|
||||
fallbackHandler.post {
|
||||
cancelFallback(notifId)
|
||||
val r = Runnable {
|
||||
fallbacks.remove(notifId)
|
||||
NotificationHelper.show(ctx, title, "Neue Nachricht", roomId, notifId)
|
||||
Log.d(TAG, "fallback placeholder shown for $roomId (decrypt too slow)")
|
||||
}
|
||||
fallbacks[notifId] = r
|
||||
fallbackHandler.postDelayed(r, delayMs)
|
||||
}
|
||||
}
|
||||
|
||||
private fun cancelFallback(notifId: Int) {
|
||||
fallbacks.remove(notifId)?.let { fallbackHandler.removeCallbacks(it) }
|
||||
}
|
||||
|
||||
/** Enqueue a task; boots the engine on first use. Must run on the main thread. */
|
||||
fun run(context: Context, method: String, args: Map<String, Any?>) {
|
||||
appContext = context.applicationContext
|
||||
Handler(Looper.getMainLooper()).post {
|
||||
try {
|
||||
ensureEngine(context.applicationContext)
|
||||
if (ready) {
|
||||
channel?.invokeMethod(method, args)
|
||||
} else {
|
||||
pending.add(method to args)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "run failed: ${e.message}")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun ensureEngine(context: Context) {
|
||||
if (engine != null) return
|
||||
|
||||
val prefs = context.getSharedPreferences("FlutterSharedPreferences", Context.MODE_PRIVATE)
|
||||
val handle = try {
|
||||
prefs.getLong("flutter.bg_engine_handle", 0L)
|
||||
} catch (_: Exception) {
|
||||
0L
|
||||
}
|
||||
if (handle == 0L) {
|
||||
Log.w(TAG, "no bg_engine_handle stored — cannot start background engine")
|
||||
return
|
||||
}
|
||||
|
||||
// The Flutter native library must be loaded BEFORE looking up the
|
||||
// callback — nativeLookupCallbackInformation is a native method and
|
||||
// throws UnsatisfiedLinkError otherwise.
|
||||
val loader = FlutterInjector.instance().flutterLoader()
|
||||
loader.startInitialization(context)
|
||||
loader.ensureInitializationComplete(context, null)
|
||||
|
||||
val cbInfo = FlutterCallbackInformation.lookupCallbackInformation(handle)
|
||||
if (cbInfo == null) {
|
||||
Log.w(TAG, "callback info not found for handle $handle")
|
||||
return
|
||||
}
|
||||
|
||||
val eng = FlutterEngine(context)
|
||||
engine = eng
|
||||
|
||||
val ch = MethodChannel(eng.dartExecutor.binaryMessenger, CHANNEL)
|
||||
channel = ch
|
||||
ch.setMethodCallHandler { call, result ->
|
||||
when (call.method) {
|
||||
"bgEngineReady" -> {
|
||||
ready = true
|
||||
Log.d(TAG, "Dart background engine ready — flushing ${pending.size} task(s)")
|
||||
pending.forEach { (m, a) -> ch.invokeMethod(m, a) }
|
||||
pending.clear()
|
||||
result.success(null)
|
||||
}
|
||||
// Dart finished decrypting — show the real content using the SAME
|
||||
// native notification (keeps the reliable MainActivity reply action).
|
||||
"showDecrypted" -> {
|
||||
val title = call.argument<String>("title") ?: ""
|
||||
val body = call.argument<String>("body") ?: "Neue Nachricht"
|
||||
val roomId = call.argument<String>("room_id") ?: ""
|
||||
val notifId = call.argument<Int>("notif_id") ?: NotificationHelper.stableId(roomId)
|
||||
val ctx = appContext
|
||||
if (ctx != null && roomId.isNotEmpty()) {
|
||||
// Beat the fallback placeholder to it — show decrypted directly.
|
||||
cancelFallback(notifId)
|
||||
NotificationHelper.show(ctx, title, body, roomId, notifId)
|
||||
Log.d(TAG, "showDecrypted → shown decrypted notification for $roomId")
|
||||
}
|
||||
result.success(null)
|
||||
}
|
||||
else -> result.notImplemented()
|
||||
}
|
||||
}
|
||||
|
||||
eng.dartExecutor.executeDartCallback(
|
||||
DartExecutor.DartCallback(context.assets, loader.findAppBundlePath(), cbInfo)
|
||||
)
|
||||
Log.d(TAG, "background engine started")
|
||||
}
|
||||
}
|
||||
@@ -3,49 +3,270 @@ package chat.pyramid.pyramid
|
||||
import android.content.Intent
|
||||
import android.net.Uri
|
||||
import android.os.Build
|
||||
import android.os.Bundle
|
||||
import android.provider.Settings
|
||||
import android.util.Log
|
||||
import androidx.core.app.RemoteInput
|
||||
import androidx.core.content.FileProvider
|
||||
import io.flutter.embedding.android.FlutterActivity
|
||||
import io.flutter.embedding.engine.FlutterEngine
|
||||
import io.flutter.embedding.engine.FlutterEngineCache
|
||||
import io.flutter.plugin.common.MethodChannel
|
||||
import java.io.File
|
||||
|
||||
class MainActivity : FlutterActivity() {
|
||||
private val channel = "chat.pyramid.pyramid/install"
|
||||
|
||||
// ── Share-Target (Teilen nach Pyramid) ────────────────────────────────────
|
||||
// Extracts shared text/streams from SEND/SEND_MULTIPLE intents. Streams are
|
||||
// copied into cacheDir because Flutter cannot read content:// URIs directly.
|
||||
private fun extractShare(intent: Intent?): Map<String, Any?>? {
|
||||
if (intent == null) return null
|
||||
val action = intent.action
|
||||
if (action != Intent.ACTION_SEND && action != Intent.ACTION_SEND_MULTIPLE) return null
|
||||
val text = intent.getStringExtra(Intent.EXTRA_TEXT)
|
||||
val uris = mutableListOf<Uri>()
|
||||
if (action == Intent.ACTION_SEND) {
|
||||
val u: Uri? = if (Build.VERSION.SDK_INT >= 33)
|
||||
intent.getParcelableExtra(Intent.EXTRA_STREAM, Uri::class.java)
|
||||
else @Suppress("DEPRECATION") intent.getParcelableExtra(Intent.EXTRA_STREAM)
|
||||
if (u != null) uris.add(u)
|
||||
} else {
|
||||
val list: ArrayList<Uri>? = if (Build.VERSION.SDK_INT >= 33)
|
||||
intent.getParcelableArrayListExtra(Intent.EXTRA_STREAM, Uri::class.java)
|
||||
else @Suppress("DEPRECATION") intent.getParcelableArrayListExtra(Intent.EXTRA_STREAM)
|
||||
if (list != null) uris.addAll(list)
|
||||
}
|
||||
if (text.isNullOrEmpty() && uris.isEmpty()) return null
|
||||
val paths = uris.mapNotNull { copyShareToCache(it) }
|
||||
return mapOf("text" to text, "paths" to paths)
|
||||
}
|
||||
|
||||
private fun copyShareToCache(uri: Uri): String? {
|
||||
return try {
|
||||
val name = contentResolver.query(uri, null, null, null, null)?.use { c ->
|
||||
val idx = c.getColumnIndex(android.provider.OpenableColumns.DISPLAY_NAME)
|
||||
if (idx >= 0 && c.moveToFirst()) c.getString(idx) else null
|
||||
} ?: "shared_${System.currentTimeMillis()}"
|
||||
val safe = name.replace(Regex("[^A-Za-z0-9._-]"), "_")
|
||||
val outFile = File(cacheDir, "share_${System.currentTimeMillis()}_$safe")
|
||||
val stream = contentResolver.openInputStream(uri) ?: return null
|
||||
stream.use { input ->
|
||||
outFile.outputStream().use { input.copyTo(it) }
|
||||
}
|
||||
outFile.absolutePath
|
||||
} catch (e: Exception) {
|
||||
Log.e("PYRAMID-SHARE", "copyShareToCache failed: ${e.message}")
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
// Extract reply text from an intent using all known key patterns.
|
||||
private fun extractReply(intent: Intent?): Pair<String?, String?> {
|
||||
if (intent == null) return Pair(null, null)
|
||||
// Only treat intents with our REPLY action as reply intents.
|
||||
if (intent.action != ReplyReceiver.ACTION_REPLY) return Pair(null, null)
|
||||
val remoteInputResults = RemoteInput.getResultsFromIntent(intent)
|
||||
val replyText: String? =
|
||||
remoteInputResults?.let { bundle ->
|
||||
// Primary key must match RemoteInput.Builder key in NotificationHelper.
|
||||
bundle.getCharSequence(ReplyReceiver.REMOTE_INPUT_KEY)?.toString()?.trim()
|
||||
?: bundle.keySet()?.firstOrNull()?.let { bundle.getCharSequence(it)?.toString()?.trim() }
|
||||
}
|
||||
?: intent.getStringExtra(ReplyReceiver.REMOTE_INPUT_KEY)
|
||||
?: intent.getStringExtra("input")
|
||||
val payload = intent.getStringExtra(ReplyReceiver.EXTRA_ROOM_ID)
|
||||
?: intent.getStringExtra("payload")
|
||||
return Pair(replyText, payload)
|
||||
}
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
// If this Activity was launched to handle a notification reply (cold start),
|
||||
// apply a fully transparent theme BEFORE super.onCreate so no UI is ever
|
||||
// drawn. The Flutter engine still initialises (needed for E2EE send via SDK)
|
||||
// but the window remains invisible.
|
||||
val (replyText, _) = extractReply(intent)
|
||||
if (!replyText.isNullOrEmpty()) {
|
||||
setTheme(R.style.ReplyHandlerTheme)
|
||||
Log.e("PYRAMID-INTENT", "onCreate: reply launch detected — transparent theme applied")
|
||||
}
|
||||
super.onCreate(savedInstanceState)
|
||||
}
|
||||
|
||||
override fun configureFlutterEngine(flutterEngine: FlutterEngine) {
|
||||
super.configureFlutterEngine(flutterEngine)
|
||||
// Cache so ReplyReceiver can call back into Dart without starting a new engine.
|
||||
FlutterEngineCache.getInstance().put("main", flutterEngine)
|
||||
MethodChannel(flutterEngine.dartExecutor.binaryMessenger, channel)
|
||||
.setMethodCallHandler { call, result ->
|
||||
if (call.method == "installApk") {
|
||||
val path = call.argument<String>("path")
|
||||
if (path == null) {
|
||||
result.error("INVALID_ARG", "path is null", null)
|
||||
return@setMethodCallHandler
|
||||
when (call.method) {
|
||||
// Flutter asks for the room_id embedded in the launch intent (cold-start tap).
|
||||
"getInitialRoomId" -> {
|
||||
result.success(intent?.getStringExtra("room_id"))
|
||||
}
|
||||
try {
|
||||
val file = File(path)
|
||||
val uri: Uri = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
|
||||
FileProvider.getUriForFile(
|
||||
this,
|
||||
"${applicationContext.packageName}.fileprovider",
|
||||
file
|
||||
)
|
||||
// Cold-start share: app was launched via "Teilen nach Pyramid".
|
||||
"getInitialShare" -> {
|
||||
val share = extractShare(intent)
|
||||
if (share != null) {
|
||||
result.success(share)
|
||||
intent = Intent() // clear so it's delivered only once
|
||||
} else {
|
||||
Uri.fromFile(file)
|
||||
result.success(null)
|
||||
}
|
||||
val intent = Intent(Intent.ACTION_VIEW).apply {
|
||||
setDataAndType(uri, "application/vnd.android.package-archive")
|
||||
addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
|
||||
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
|
||||
}
|
||||
// Flutter asks for any inline reply that arrived on cold-start.
|
||||
// When the app is killed and the user presses Reply, Android calls
|
||||
// onCreate (not onNewIntent), so the reply text is in the launch intent.
|
||||
"getInitialReply" -> {
|
||||
val (replyText, payload) = extractReply(intent)
|
||||
Log.e("PYRAMID-INTENT", "getInitialReply: replyText=${replyText?.take(20)} payload=$payload")
|
||||
if (!replyText.isNullOrEmpty() && !payload.isNullOrEmpty()) {
|
||||
result.success(mapOf("room_id" to payload, "text" to replyText))
|
||||
// Clear the launch intent so a second getInitialReply call returns null.
|
||||
intent = Intent()
|
||||
} else {
|
||||
result.success(null)
|
||||
}
|
||||
}
|
||||
// Supported CPU ABIs in preference order, so the updater can
|
||||
// pick the matching split APK from a release.
|
||||
"getAbis" -> {
|
||||
result.success(Build.SUPPORTED_ABIS.toList())
|
||||
}
|
||||
"installApk" -> {
|
||||
val path = call.argument<String>("path")
|
||||
if (path == null) {
|
||||
result.error("INVALID_ARG", "path is null", null)
|
||||
return@setMethodCallHandler
|
||||
}
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O &&
|
||||
!packageManager.canRequestPackageInstalls()) {
|
||||
val intent = Intent(Settings.ACTION_MANAGE_UNKNOWN_APP_SOURCES).apply {
|
||||
data = Uri.parse("package:$packageName")
|
||||
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
|
||||
}
|
||||
startActivity(intent)
|
||||
result.error("PERMISSION_REQUIRED", "install_permission", null)
|
||||
return@setMethodCallHandler
|
||||
}
|
||||
try {
|
||||
val file = File(path)
|
||||
val uri: Uri = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
|
||||
FileProvider.getUriForFile(
|
||||
this,
|
||||
"${applicationContext.packageName}.fileprovider",
|
||||
file
|
||||
)
|
||||
} else {
|
||||
Uri.fromFile(file)
|
||||
}
|
||||
val intent = Intent(Intent.ACTION_VIEW).apply {
|
||||
setDataAndType(uri, "application/vnd.android.package-archive")
|
||||
addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
|
||||
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
|
||||
}
|
||||
startActivity(intent)
|
||||
result.success(null)
|
||||
} catch (e: Exception) {
|
||||
result.error("INSTALL_ERROR", e.message, null)
|
||||
}
|
||||
}
|
||||
"openNotificationSettings" -> {
|
||||
val intent = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||
Intent(Settings.ACTION_APP_NOTIFICATION_SETTINGS).apply {
|
||||
putExtra(Settings.EXTRA_APP_PACKAGE, packageName)
|
||||
}
|
||||
} else {
|
||||
Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS).apply {
|
||||
data = Uri.fromParts("package", packageName, null)
|
||||
}
|
||||
}
|
||||
intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
|
||||
startActivity(intent)
|
||||
result.success(null)
|
||||
} catch (e: Exception) {
|
||||
result.error("INSTALL_ERROR", e.message, null)
|
||||
}
|
||||
} else {
|
||||
result.notImplemented()
|
||||
"openBatterySettings" -> {
|
||||
val intent = Intent(Settings.ACTION_REQUEST_IGNORE_BATTERY_OPTIMIZATIONS).apply {
|
||||
data = Uri.parse("package:$packageName")
|
||||
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
|
||||
}
|
||||
try {
|
||||
startActivity(intent)
|
||||
} catch (e: Exception) {
|
||||
val fallback = Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS).apply {
|
||||
data = Uri.fromParts("package", packageName, null)
|
||||
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
|
||||
}
|
||||
startActivity(fallback)
|
||||
}
|
||||
result.success(null)
|
||||
}
|
||||
"minimizeApp" -> {
|
||||
moveTaskToBack(true)
|
||||
result.success(null)
|
||||
}
|
||||
"showNativeNotification" -> {
|
||||
val title = call.argument<String>("title") ?: ""
|
||||
val body = call.argument<String>("body") ?: ""
|
||||
val roomId = call.argument<String>("roomId") ?: ""
|
||||
val notifId = call.argument<Int>("notifId") ?: NotificationHelper.stableId(roomId)
|
||||
if (roomId.isNotEmpty()) {
|
||||
NotificationHelper.show(this, title, body, roomId, notifId)
|
||||
result.success(null)
|
||||
} else {
|
||||
result.error("INVALID_ARG", "roomId missing", null)
|
||||
}
|
||||
}
|
||||
else -> result.notImplemented()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Called when the app is already running (background) and a notification action
|
||||
// fires — reply and tap both arrive here via onNewIntent (singleTop + SINGLE_TOP flag).
|
||||
override fun onNewIntent(intent: Intent) {
|
||||
// Zero-duration transition for all notification-driven intents.
|
||||
overridePendingTransition(0, 0)
|
||||
|
||||
super.onNewIntent(intent)
|
||||
setIntent(intent)
|
||||
|
||||
Log.e("PYRAMID-INTENT", "onNewIntent action=${intent.action}")
|
||||
|
||||
// ── Reply action ──────────────────────────────────────────────────────
|
||||
if (intent.action == ReplyReceiver.ACTION_REPLY) {
|
||||
val (replyText, payload) = extractReply(intent)
|
||||
Log.e("PYRAMID-INTENT", "reply: replyText=${replyText?.take(20)} payload=$payload")
|
||||
if (!replyText.isNullOrEmpty() && !payload.isNullOrEmpty()) {
|
||||
flutterEngine?.dartExecutor?.binaryMessenger?.let { messenger ->
|
||||
MethodChannel(messenger, channel).invokeMethod(
|
||||
"replyFromNotification",
|
||||
mapOf("room_id" to payload, "text" to replyText),
|
||||
)
|
||||
}
|
||||
}
|
||||
// Always send back immediately — never show the app UI for a reply.
|
||||
moveTaskToBack(true)
|
||||
overridePendingTransition(0, 0)
|
||||
return
|
||||
}
|
||||
|
||||
// ── Share action — app already running, user shared from another app ──
|
||||
val share = extractShare(intent)
|
||||
if (share != null) {
|
||||
flutterEngine?.dartExecutor?.binaryMessenger?.let { messenger ->
|
||||
MethodChannel(messenger, channel).invokeMethod("sharedContent", share)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// ── Tap action — open the room ────────────────────────────────────────
|
||||
val roomId = intent.getStringExtra("room_id") ?: return
|
||||
flutterEngine?.dartExecutor?.binaryMessenger?.let { messenger ->
|
||||
MethodChannel(messenger, channel).invokeMethod(
|
||||
"openRoom",
|
||||
mapOf("room_id" to roomId),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,107 @@
|
||||
package chat.pyramid.pyramid
|
||||
|
||||
import android.app.NotificationChannel
|
||||
import android.app.NotificationManager
|
||||
import android.app.PendingIntent
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.graphics.Color
|
||||
import android.os.Build
|
||||
import androidx.core.app.NotificationCompat
|
||||
import androidx.core.app.RemoteInput
|
||||
|
||||
object NotificationHelper {
|
||||
const val CHANNEL_ID = "pyramid_messages"
|
||||
|
||||
/** Deterministic notification ID — same algorithm as Dart's _stableRoomId. */
|
||||
fun stableId(roomId: String): Int {
|
||||
var h = 0
|
||||
for (c in roomId) h = ((h * 31) + c.code) and 0x7FFFFFFF
|
||||
return if (h == 0) 1 else h
|
||||
}
|
||||
|
||||
fun ensureChannel(context: Context) {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||
val channel = NotificationChannel(
|
||||
CHANNEL_ID, "Nachrichten", NotificationManager.IMPORTANCE_HIGH
|
||||
).apply {
|
||||
description = "Neue Nachrichten"
|
||||
enableVibration(true)
|
||||
}
|
||||
nm(context).createNotificationChannel(channel)
|
||||
}
|
||||
}
|
||||
|
||||
fun show(context: Context, title: String, body: String, roomId: String, notifId: Int) {
|
||||
ensureChannel(context)
|
||||
|
||||
val mutFlag = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S)
|
||||
PendingIntent.FLAG_MUTABLE else 0
|
||||
|
||||
// Reply action — BroadcastReceiver so replying NEVER opens the app.
|
||||
// ReplyReceiver decrypts/sends via the background engine when killed.
|
||||
// (ReplyReceiver has multiple RemoteInput key fallbacks for OEM quirks.)
|
||||
val replyInput = RemoteInput.Builder(ReplyReceiver.REMOTE_INPUT_KEY)
|
||||
.setLabel("Antworten…").build()
|
||||
val replyIntent = Intent(context, ReplyReceiver::class.java).apply {
|
||||
action = ReplyReceiver.ACTION_REPLY
|
||||
putExtra(ReplyReceiver.EXTRA_ROOM_ID, roomId)
|
||||
putExtra(ReplyReceiver.EXTRA_NOTIF_ID, notifId)
|
||||
}
|
||||
val replyPi = PendingIntent.getBroadcast(
|
||||
context, notifId,
|
||||
replyIntent,
|
||||
PendingIntent.FLAG_UPDATE_CURRENT or mutFlag,
|
||||
)
|
||||
val replyAction = NotificationCompat.Action.Builder(0, "Antworten", replyPi)
|
||||
.addRemoteInput(replyInput)
|
||||
.setSemanticAction(NotificationCompat.Action.SEMANTIC_ACTION_REPLY)
|
||||
.build()
|
||||
|
||||
// Dismiss action — also points to ReplyReceiver so notification is cancelled.
|
||||
val dismissIntent = Intent(context, ReplyReceiver::class.java).apply {
|
||||
action = ReplyReceiver.ACTION_DISMISS
|
||||
putExtra(ReplyReceiver.EXTRA_ROOM_ID, roomId)
|
||||
putExtra(ReplyReceiver.EXTRA_NOTIF_ID, notifId)
|
||||
}
|
||||
val dismissPi = PendingIntent.getBroadcast(
|
||||
context, notifId + 1_000_000,
|
||||
dismissIntent,
|
||||
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
|
||||
)
|
||||
|
||||
// Tap action — opens MainActivity to the right room.
|
||||
val tapIntent = Intent(context, MainActivity::class.java).apply {
|
||||
action = Intent.ACTION_MAIN
|
||||
addCategory(Intent.CATEGORY_LAUNCHER)
|
||||
putExtra("room_id", roomId)
|
||||
flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_SINGLE_TOP
|
||||
}
|
||||
val tapPi = PendingIntent.getActivity(
|
||||
context, notifId + 2_000_000,
|
||||
tapIntent,
|
||||
PendingIntent.FLAG_UPDATE_CURRENT or mutFlag,
|
||||
)
|
||||
|
||||
val notif = NotificationCompat.Builder(context, CHANNEL_ID)
|
||||
.setSmallIcon(R.drawable.ic_notification)
|
||||
.setColor(Color.rgb(0x7B, 0x61, 0xFF))
|
||||
.setContentTitle(title)
|
||||
.setContentText(body)
|
||||
.setPriority(NotificationCompat.PRIORITY_HIGH)
|
||||
// Only buzz/heads-up on the first show — the decrypted update reuses
|
||||
// the same notifId and replaces the text silently (no second alert).
|
||||
.setOnlyAlertOnce(true)
|
||||
.setContentIntent(tapPi)
|
||||
.setAutoCancel(true)
|
||||
.addAction(replyAction)
|
||||
.addAction(NotificationCompat.Action(0, "Gelesen", dismissPi))
|
||||
.setGroup(CHANNEL_ID)
|
||||
.build()
|
||||
|
||||
nm(context).notify(notifId, notif)
|
||||
}
|
||||
|
||||
private fun nm(context: Context) =
|
||||
context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
|
||||
}
|
||||
@@ -0,0 +1,160 @@
|
||||
package chat.pyramid.pyramid
|
||||
|
||||
import android.os.Handler
|
||||
import android.os.Looper
|
||||
import android.util.Log
|
||||
import com.google.firebase.messaging.FirebaseMessagingService
|
||||
import com.google.firebase.messaging.RemoteMessage
|
||||
import io.flutter.embedding.engine.FlutterEngineCache
|
||||
import io.flutter.plugin.common.MethodChannel
|
||||
import org.json.JSONObject
|
||||
import java.net.HttpURLConnection
|
||||
import java.net.URL
|
||||
import java.util.concurrent.CountDownLatch
|
||||
import java.util.concurrent.TimeUnit
|
||||
|
||||
class PushService : FirebaseMessagingService() {
|
||||
|
||||
override fun onMessageReceived(message: RemoteMessage) {
|
||||
val data = message.data
|
||||
val roomId = data["room_id"] ?: run {
|
||||
Log.d(TAG, "no room_id in payload, skipping")
|
||||
return
|
||||
}
|
||||
|
||||
val prefs = getSharedPreferences("FlutterSharedPreferences", MODE_PRIVATE)
|
||||
|
||||
// Skip if the Dart app is actively syncing (wrote a heartbeat < 20 s ago).
|
||||
val heartbeat = try {
|
||||
prefs.getLong("flutter.notif_app_heartbeat", 0L)
|
||||
.takeIf { it > 0L }
|
||||
?: prefs.getInt("flutter.notif_app_heartbeat", 0).toLong()
|
||||
} catch (_: Exception) { 0L }
|
||||
if (System.currentTimeMillis() - heartbeat < 20_000L) {
|
||||
Log.d(TAG, "app heartbeat fresh, skipping FCM notification")
|
||||
return
|
||||
}
|
||||
|
||||
val eventId = data["event_id"]
|
||||
val notifId = NotificationHelper.stableId(roomId)
|
||||
val hs = (prefs.getString("flutter.notif_homeserver", "") ?: "").trimEnd('/')
|
||||
val token = (prefs.getString("flutter.notif_access_token", "") ?: "")
|
||||
|
||||
// Fetch sender + room name on a background thread (main thread forbids I/O).
|
||||
var title = "Pyramid"
|
||||
if (hs.isNotEmpty() && token.isNotEmpty() && eventId != null) {
|
||||
val latch = CountDownLatch(1)
|
||||
Thread {
|
||||
try { title = fetchTitle(hs, token, roomId, eventId) ?: "Pyramid" }
|
||||
finally { latch.countDown() }
|
||||
}.start()
|
||||
latch.await(12L, TimeUnit.SECONDS)
|
||||
}
|
||||
|
||||
Log.d(TAG, "handling push: title=$title roomId=$roomId")
|
||||
|
||||
val engine = FlutterEngineCache.getInstance().get("main")
|
||||
if (engine != null) {
|
||||
// App backgrounded but alive — show placeholder immediately, then the
|
||||
// warm main client decrypts and updates it (fast, barely visible).
|
||||
NotificationHelper.show(this, title, "Neue Nachricht", roomId, notifId)
|
||||
if (eventId != null) {
|
||||
val capturedRoomId = roomId
|
||||
val capturedEventId = eventId
|
||||
val capturedNotifId = notifId
|
||||
Handler(Looper.getMainLooper()).post {
|
||||
try {
|
||||
MethodChannel(engine.dartExecutor.binaryMessenger,
|
||||
"chat.pyramid.pyramid/install")
|
||||
.invokeMethod("decryptAndUpdateNotification", mapOf(
|
||||
"room_id" to capturedRoomId,
|
||||
"event_id" to capturedEventId,
|
||||
"notif_id" to capturedNotifId,
|
||||
))
|
||||
Log.d(TAG, "decryptAndUpdateNotification invoked (main engine) for $capturedRoomId")
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "decryptAndUpdateNotification failed: ${e.message}")
|
||||
}
|
||||
}
|
||||
}
|
||||
} else if (eventId != null) {
|
||||
// App killed — decrypt FIRST via the background engine, then show the
|
||||
// real content directly. The "Neue Nachricht" placeholder is only a
|
||||
// fallback if decryption takes too long or fails.
|
||||
Log.d(TAG, "main engine gone — decrypt-first via background engine for $roomId")
|
||||
BgEngine.scheduleFallback(this, title, roomId, notifId, 6_000L)
|
||||
BgEngine.run(this, "decryptAndShow", mapOf(
|
||||
"room_id" to roomId,
|
||||
"event_id" to eventId,
|
||||
"notif_id" to notifId,
|
||||
))
|
||||
} else {
|
||||
// No event id to decrypt — just show the placeholder.
|
||||
NotificationHelper.show(this, title, "Neue Nachricht", roomId, notifId)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onNewToken(token: String) {
|
||||
Log.d(TAG, "FCM token refreshed — storing for Dart to re-register pusher")
|
||||
getSharedPreferences("FlutterSharedPreferences", MODE_PRIVATE)
|
||||
.edit()
|
||||
.putString("flutter.fcm_pending_token", token)
|
||||
.apply()
|
||||
}
|
||||
|
||||
// ─── HTTP helpers ─────────────────────────────────────────────────────────
|
||||
|
||||
private fun fetchTitle(
|
||||
hs: String, token: String, roomId: String, eventId: String
|
||||
): String? {
|
||||
return try {
|
||||
val encRoom = java.net.URLEncoder.encode(roomId, "UTF-8")
|
||||
val encEvent = java.net.URLEncoder.encode(eventId, "UTF-8")
|
||||
|
||||
val event = httpGet("$hs/_matrix/client/v3/rooms/$encRoom/event/$encEvent", token)
|
||||
?: return null
|
||||
|
||||
val senderId = event.optString("sender")
|
||||
var senderName = senderId.split(":").firstOrNull()?.removePrefix("@") ?: ""
|
||||
|
||||
if (senderId.isNotEmpty()) {
|
||||
val encSender = java.net.URLEncoder.encode(senderId, "UTF-8")
|
||||
val member = httpGet(
|
||||
"$hs/_matrix/client/v3/rooms/$encRoom/state/m.room.member/$encSender", token)
|
||||
val name = member?.optString("displayname")
|
||||
if (!name.isNullOrEmpty()) senderName = name
|
||||
}
|
||||
|
||||
val nameState = httpGet("$hs/_matrix/client/v3/rooms/$encRoom/state/m.room.name", token)
|
||||
val roomName = nameState?.optString("name")
|
||||
|
||||
return if (!roomName.isNullOrEmpty()) "$senderName · $roomName"
|
||||
else senderName.ifEmpty { null }
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "fetchTitle failed: ${e.message}")
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
private fun httpGet(url: String, token: String): JSONObject? {
|
||||
var conn: HttpURLConnection? = null
|
||||
return try {
|
||||
conn = (URL(url).openConnection() as HttpURLConnection).apply {
|
||||
requestMethod = "GET"
|
||||
setRequestProperty("Authorization", "Bearer $token")
|
||||
connectTimeout = 8_000
|
||||
readTimeout = 8_000
|
||||
}
|
||||
if (conn.responseCode != 200) return null
|
||||
JSONObject(conn.inputStream.bufferedReader().readText())
|
||||
} catch (_: Exception) {
|
||||
null
|
||||
} finally {
|
||||
conn?.disconnect()
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val TAG = "PYRAMID-PUSH"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
package chat.pyramid.pyramid
|
||||
|
||||
import android.app.NotificationManager
|
||||
import android.content.BroadcastReceiver
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.util.Log
|
||||
import androidx.core.app.RemoteInput
|
||||
import io.flutter.embedding.engine.FlutterEngineCache
|
||||
import io.flutter.plugin.common.MethodChannel
|
||||
|
||||
class ReplyReceiver : BroadcastReceiver() {
|
||||
|
||||
companion object {
|
||||
const val ACTION_REPLY = "chat.pyramid.pyramid.REPLY"
|
||||
const val ACTION_DISMISS = "chat.pyramid.pyramid.DISMISS"
|
||||
const val EXTRA_ROOM_ID = "room_id"
|
||||
const val EXTRA_NOTIF_ID = "notif_id"
|
||||
const val REMOTE_INPUT_KEY = "reply_text"
|
||||
}
|
||||
|
||||
override fun onReceive(context: Context, intent: Intent) {
|
||||
val roomId = intent.getStringExtra(EXTRA_ROOM_ID) ?: return
|
||||
val notifId = intent.getIntExtra(EXTRA_NOTIF_ID, 0)
|
||||
|
||||
// Dismiss the notification immediately.
|
||||
(context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager)
|
||||
.cancel(notifId)
|
||||
|
||||
if (intent.action == ACTION_DISMISS) {
|
||||
Log.d("PYRAMID-REPLY", "dismiss action for room $roomId")
|
||||
return
|
||||
}
|
||||
|
||||
// Extract inline reply text. Some Samsung/MIUI/OEM devices drop the
|
||||
// RemoteInput bundle or use a different key — try multiple fallbacks.
|
||||
val remoteInput = RemoteInput.getResultsFromIntent(intent)
|
||||
val replyText = remoteInput?.let { bundle ->
|
||||
bundle.getCharSequence(REMOTE_INPUT_KEY)?.toString()?.trim()
|
||||
?: bundle.keySet()?.firstOrNull()?.let { bundle.getCharSequence(it)?.toString()?.trim() }
|
||||
} ?: intent.getStringExtra(REMOTE_INPUT_KEY)
|
||||
?: intent.getStringExtra("input")
|
||||
?: intent.getStringExtra("notification_action_input")
|
||||
?: intent.getStringExtra("reply_text")
|
||||
Log.d("PYRAMID-REPLY", "extracted replyText=${replyText?.take(20)} (remoteInput=${remoteInput != null})")
|
||||
if (replyText.isNullOrEmpty()) {
|
||||
Log.w("PYRAMID-REPLY", "reply text empty after all fallbacks, aborting")
|
||||
return
|
||||
}
|
||||
|
||||
Log.d("PYRAMID-REPLY", "reply received: room=$roomId text=$replyText")
|
||||
|
||||
// Fast path: Flutter engine is alive — forward directly via MethodChannel.
|
||||
val engine = FlutterEngineCache.getInstance().get("main")
|
||||
if (engine != null) {
|
||||
Log.d("PYRAMID-REPLY", "Flutter engine alive → MethodChannel")
|
||||
try {
|
||||
MethodChannel(engine.dartExecutor.binaryMessenger, "chat.pyramid.pyramid/install")
|
||||
.invokeMethod("replyFromNotification",
|
||||
mapOf("room_id" to roomId, "text" to replyText))
|
||||
return
|
||||
} catch (e: Exception) {
|
||||
Log.w("PYRAMID-REPLY", "MethodChannel failed: ${e.message}, falling back to prefs")
|
||||
}
|
||||
}
|
||||
|
||||
// Slow path: app killed — send the encrypted reply via the background
|
||||
// engine (no Activity, fully silent). _bgSendReply falls back to the
|
||||
// prefs queue itself if it can't send, so nothing is lost.
|
||||
// goAsync() keeps this BroadcastReceiver's process alive while the engine
|
||||
// boots + sends (otherwise it could be killed right after onReceive).
|
||||
Log.d("PYRAMID-REPLY", "Flutter engine not running → background engine send")
|
||||
val pending = goAsync()
|
||||
BgEngine.run(context, "sendReply", mapOf(
|
||||
"room_id" to roomId,
|
||||
"text" to replyText,
|
||||
))
|
||||
// Release after a window long enough for sync + encrypted send (~4 s seen
|
||||
// in logs; 15 s gives margin). The engine completes independently.
|
||||
android.os.Handler(android.os.Looper.getMainLooper()).postDelayed({
|
||||
try { pending.finish() } catch (_: Exception) {}
|
||||
}, 15_000L)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!--
|
||||
Adaptive icon foreground — 108×108dp canvas.
|
||||
Safe zone: inner 66dp (21–87). Pyramid fills ~56% of visible 72dp circle.
|
||||
Derived from assets/logo.svg (48×48 viewBox, scale=1.1, centered at (54,54)).
|
||||
Draw order matches SVG: right face behind, left face in front, then strokes on top.
|
||||
-->
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:aapt="http://schemas.android.com/aapt"
|
||||
android:width="108dp"
|
||||
android:height="108dp"
|
||||
android:viewportWidth="108"
|
||||
android:viewportHeight="108">
|
||||
|
||||
<!-- Right face — pyr-side, diagonal gradient 85%→25% opacity -->
|
||||
<path android:pathData="M54,35 L74,73 L54,66 Z">
|
||||
<aapt:attr name="android:fillColor">
|
||||
<gradient
|
||||
android:type="linear"
|
||||
android:startX="54" android:startY="35"
|
||||
android:endX="74" android:endY="73">
|
||||
<item android:offset="0" android:color="#D9F5A524"/>
|
||||
<item android:offset="1" android:color="#40F5A524"/>
|
||||
</gradient>
|
||||
</aapt:attr>
|
||||
</path>
|
||||
|
||||
<!-- Left face — pyr-face, vertical gradient 100%→55% opacity -->
|
||||
<path android:pathData="M54,35 L34,73 L54,66 Z">
|
||||
<aapt:attr name="android:fillColor">
|
||||
<gradient
|
||||
android:type="linear"
|
||||
android:startX="54" android:startY="35"
|
||||
android:endX="54" android:endY="73">
|
||||
<item android:offset="0" android:color="#FFF5A524"/>
|
||||
<item android:offset="1" android:color="#8CF5A524"/>
|
||||
</gradient>
|
||||
</aapt:attr>
|
||||
</path>
|
||||
|
||||
<!-- Base outline — stroke 40% opacity, width 1.1dp, linejoin round -->
|
||||
<path android:pathData="M34,73 L54,66 L74,73"
|
||||
android:fillColor="#00000000"
|
||||
android:strokeColor="#66F5A524"
|
||||
android:strokeWidth="1.1"
|
||||
android:strokeLineJoin="round"/>
|
||||
|
||||
<!-- Edge highlight / center spine — stroke 90% opacity, width 0.88dp -->
|
||||
<path android:pathData="M54,35 L54,66"
|
||||
android:fillColor="#00000000"
|
||||
android:strokeColor="#E6F5A524"
|
||||
android:strokeWidth="0.9"/>
|
||||
|
||||
</vector>
|
||||
@@ -0,0 +1,15 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:viewportWidth="48"
|
||||
android:viewportHeight="48">
|
||||
<!-- Pyramid shape: left face -->
|
||||
<path
|
||||
android:pathData="M24,6 L6,40 L24,34 Z"
|
||||
android:fillColor="#FFFFFF"/>
|
||||
<!-- Pyramid shape: right face -->
|
||||
<path
|
||||
android:pathData="M24,6 L42,40 L24,34 Z"
|
||||
android:fillColor="#CCFFFFFF"/>
|
||||
</vector>
|
||||
@@ -0,0 +1,5 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<background android:drawable="@color/ic_launcher_background"/>
|
||||
<foreground android:drawable="@drawable/ic_launcher_foreground"/>
|
||||
</adaptive-icon>
|
||||
|
Before Width: | Height: | Size: 544 B After Width: | Height: | Size: 2.6 KiB |
|
Before Width: | Height: | Size: 442 B After Width: | Height: | Size: 1.8 KiB |
|
Before Width: | Height: | Size: 721 B After Width: | Height: | Size: 3.5 KiB |
|
Before Width: | Height: | Size: 1.0 KiB After Width: | Height: | Size: 5.4 KiB |
|
Before Width: | Height: | Size: 1.4 KiB After Width: | Height: | Size: 7.8 KiB |
@@ -15,4 +15,10 @@
|
||||
<style name="NormalTheme" parent="@android:style/Theme.Black.NoTitleBar">
|
||||
<item name="android:windowBackground">?android:colorBackground</item>
|
||||
</style>
|
||||
<style name="ReplyHandlerTheme" parent="@android:style/Theme.Translucent.NoTitleBar">
|
||||
<item name="android:windowIsTranslucent">true</item>
|
||||
<item name="android:windowBackground">@android:color/transparent</item>
|
||||
<item name="android:windowContentOverlay">@null</item>
|
||||
<item name="android:backgroundDimEnabled">false</item>
|
||||
</style>
|
||||
</resources>
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<color name="ic_launcher_background">#1A1B1E</color>
|
||||
</resources>
|
||||
@@ -15,4 +15,12 @@
|
||||
<style name="NormalTheme" parent="@android:style/Theme.Light.NoTitleBar">
|
||||
<item name="android:windowBackground">?android:colorBackground</item>
|
||||
</style>
|
||||
<!-- Used when the activity is launched solely to handle a notification reply.
|
||||
The window is fully transparent so the user never sees the UI open. -->
|
||||
<style name="ReplyHandlerTheme" parent="@android:style/Theme.Translucent.NoTitleBar">
|
||||
<item name="android:windowIsTranslucent">true</item>
|
||||
<item name="android:windowBackground">@android:color/transparent</item>
|
||||
<item name="android:windowContentOverlay">@null</item>
|
||||
<item name="android:backgroundDimEnabled">false</item>
|
||||
</style>
|
||||
</resources>
|
||||
|
||||
@@ -2,4 +2,4 @@ distributionBase=GRADLE_USER_HOME
|
||||
distributionPath=wrapper/dists
|
||||
zipStoreBase=GRADLE_USER_HOME
|
||||
zipStorePath=wrapper/dists
|
||||
distributionUrl=https\://services.gradle.org/distributions/gradle-8.14-all.zip
|
||||
distributionUrl=https\://services.gradle.org/distributions/gradle-8.13-all.zip
|
||||
|
||||
@@ -21,6 +21,7 @@ plugins {
|
||||
id("dev.flutter.flutter-plugin-loader") version "1.0.0"
|
||||
id("com.android.application") version "8.11.1" apply false
|
||||
id("org.jetbrains.kotlin.android") version "2.2.20" apply false
|
||||
id("com.google.gms.google-services") version "4.4.4" apply false
|
||||
}
|
||||
|
||||
include(":app")
|
||||
|
||||