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
This commit is contained in:
Bernd Steckmeister
2026-07-03 05:47:18 +02:00
parent d706ace35f
commit 25ed765a03
195 changed files with 47784 additions and 1236 deletions
+46 -5
View File
@@ -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 {
+29
View File
@@ -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"
}
+18
View File
@@ -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.**
+75 -1
View File
@@ -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 (2187). 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>
Binary file not shown.

Before

Width:  |  Height:  |  Size: 544 B

After

Width:  |  Height:  |  Size: 2.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 442 B

After

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 721 B

After

Width:  |  Height:  |  Size: 3.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.0 KiB

After

Width:  |  Height:  |  Size: 5.4 KiB

Binary file not shown.

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>
+1 -1
View File
@@ -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
+1
View File
@@ -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")