WIP: manual app notifications

This commit is contained in:
lone-cloud 2026-02-17 00:44:57 -08:00
parent 1de7918203
commit bbce35a667
13 changed files with 521 additions and 83 deletions

View file

@ -6,6 +6,7 @@
- Only add comments for complex logic, non-obvious behavior, or important context - Only add comments for complex logic, non-obvious behavior, or important context
- Prefer self-documenting code with clear variable and function names over comments - Prefer self-documenting code with clear variable and function names over comments
- Avoid redundant comments like "// Create button" or "// Set text color" - Avoid redundant comments like "// Create button" or "// Set text color"
- Do NOT add copyright headers to new files
## Examples of BAD comments to avoid: ## Examples of BAD comments to avoid:
```kotlin ```kotlin

View file

@ -74,6 +74,10 @@
<action android:name="org.unifiedpush.android.distributor.UNREGISTER" /> <action android:name="org.unifiedpush.android.distributor.UNREGISTER" />
</intent-filter> </intent-filter>
</receiver> </receiver>
<receiver
android:name=".receivers.NotificationActionReceiver"
android:enabled="true"
android:exported="false" />
</application> </application>
</manifest> </manifest>

View file

@ -9,6 +9,7 @@
package app.lonecloud.prism package app.lonecloud.prism
import android.content.Context import android.content.Context
import android.util.Log
import app.lonecloud.prism.api.MessageSender import app.lonecloud.prism.api.MessageSender
import app.lonecloud.prism.api.data.ClientMessage import app.lonecloud.prism.api.data.ClientMessage
import org.unifiedpush.android.distributor.Database import org.unifiedpush.android.distributor.Database
@ -47,10 +48,26 @@ object Distributor : UnifiedPushDistributor() {
) = backendRegisterNewChannelId(context, packageName, channelId, title, vapid, description) ) = backendRegisterNewChannelId(context, packageName, channelId, title, vapid, description)
override fun backendUnregisterChannelId(context: Context, channelId: String) { override fun backendUnregisterChannelId(context: Context, channelId: String) {
Log.d("Distributor", "backendUnregisterChannelId called with channelId: $channelId")
val db = getDb(context) val db = getDb(context)
val app = db.listApps().find { it.connectorToken == channelId }
val channelVapidPair = db.listChannelIdVapid().find { it.first == channelId }
Log.d("Distributor", "Found vapidKey for channelId: ${channelVapidPair?.second}")
if (channelVapidPair != null) {
val app = db.listApps().find { it.vapidKey == channelVapidPair.second }
Log.d(
"Distributor",
"Found app: ${app?.title}, isManual: ${app?.description?.startsWith("target:")}, connectorToken: ${app?.connectorToken}"
)
if (app?.description?.startsWith("target:") == true) { if (app?.description?.startsWith("target:") == true) {
PrismServerClient.deleteApp(context, channelId) Log.d("Distributor", "Calling PrismServerClient.deleteApp with connectorToken: ${app.connectorToken}")
PrismServerClient.deleteApp(context, app.connectorToken)
}
} else {
Log.w("Distributor", "No vapidKey found for channelId: $channelId")
} }
MessageSender.send( MessageSender.send(

View file

@ -49,34 +49,44 @@ object PrismServerClient {
try { try {
val json = JSONObject().apply { val json = JSONObject().apply {
put("appName", appName) put("appName", appName)
put("webpushUrl", webpushUrl) put("pushEndpoint", webpushUrl)
vapidPrivateKey?.let { put("vapidPrivateKey", it) } vapidPrivateKey?.let { put("vapidPrivateKey", it) }
p256dh?.let { put("p256dh", it) } p256dh?.let { put("p256dh", it) }
auth?.let { put("auth", it) } auth?.let { put("auth", it) }
} }
val url = "$serverUrl/api/v1/webpush/subscriptions"
val request = Request.Builder() val request = Request.Builder()
.url("$serverUrl/api/v1/webpush/subscriptions") .url(url)
.addHeader("Authorization", getAuthHeader(apiKey)) .addHeader("Authorization", getAuthHeader(apiKey))
.addHeader("Content-Type", "application/json") .addHeader("Content-Type", "application/json")
.post(json.toString().toRequestBody("application/json".toMediaType())) .post(json.toString().toRequestBody("application/json".toMediaType()))
.build() .build()
Log.d(TAG, "Registering app with Prism server: $appName -> $webpushUrl") Log.d(TAG, "Registering app with Prism server: $appName")
client.newCall(request).execute().use { response -> client.newCall(request).execute().use { response ->
if (response.isSuccessful) { if (response.isSuccessful) {
val responseBody = response.body.string() val responseBody = response.body.string()
try {
val responseJson = JSONObject(responseBody) val responseJson = JSONObject(responseBody)
val subscriptionId = responseJson.getLong("id") val subscriptionId = responseJson.getString("subscriptionId").toLong()
store.setSubscriptionId(connectorToken, subscriptionId) store.setSubscriptionId(connectorToken, subscriptionId)
Log.d(TAG, "Successfully registered app: $appName (subscription ID: $subscriptionId)") Log.d(TAG, "Successfully registered app: $appName (ID: $subscriptionId)")
withContext(Dispatchers.Main) { onSuccess() } withContext(Dispatchers.Main) { onSuccess() }
} else { } catch (e: Exception) {
val error = "Failed to register app: ${response.code} ${response.message}" val error = "Failed to parse registration response: ${e.message}"
Log.e(TAG, error) Log.e(TAG, error)
Log.e(TAG, "Response body: $responseBody")
withContext(Dispatchers.Main) { onError(error) }
}
} else {
val responseBody = response.body.string()
val error = "Failed to register app: ${response.code} ${response.message}"
Log.e(TAG, "$error - Response: $responseBody")
withContext(Dispatchers.Main) { onError(error) } withContext(Dispatchers.Main) { onError(error) }
} }
} }
@ -110,13 +120,31 @@ object PrismServerClient {
manualApps.forEach { app -> manualApps.forEach { app ->
app.endpoint?.let { endpoint -> app.endpoint?.let { endpoint ->
val appName = app.title ?: app.packageName val appName = app.title ?: app.packageName
val channelId = db.listChannelIdVapid()
.find { (_, vapid) -> vapid == app.vapidKey }
?.first
val keyStore = EncryptionKeyStore(context)
val keys = channelId?.let { keyStore.getKeys(it) }
registerApp( registerApp(
context, context,
app.connectorToken, app.connectorToken,
appName, appName,
endpoint endpoint,
vapidPrivateKey = app.vapidKey,
p256dh = keys?.third,
auth = keys?.second?.let { authBytes ->
android.util.Base64.encodeToString(
authBytes,
android.util.Base64.URL_SAFE or android.util.Base64.NO_PADDING or android.util.Base64.NO_WRAP
) )
} }
)
} ?: run {
Log.w(TAG, "Skipping app ${app.title} - no endpoint available")
}
} }
} catch (e: IOException) { } catch (e: IOException) {
Log.e(TAG, "Error during bulk registration: ${e.message}", e) Log.e(TAG, "Error during bulk registration: ${e.message}", e)
@ -136,14 +164,18 @@ object PrismServerClient {
val url = serverUrl ?: store.prismServerUrl val url = serverUrl ?: store.prismServerUrl
val key = apiKey ?: store.prismApiKey val key = apiKey ?: store.prismApiKey
Log.d(TAG, "deleteApp called for connectorToken: $connectorToken")
if (url.isNullOrBlank() || key.isNullOrBlank()) { if (url.isNullOrBlank() || key.isNullOrBlank()) {
Log.d(TAG, "Prism server not configured, skipping deletion") Log.d(TAG, "Prism server not configured, skipping deletion")
return return
} }
val subscriptionId = store.getSubscriptionId(connectorToken) val subscriptionId = store.getSubscriptionId(connectorToken)
Log.d(TAG, "Retrieved subscriptionId: $subscriptionId for token: $connectorToken")
if (subscriptionId == null) { if (subscriptionId == null) {
Log.d(TAG, "No subscription ID found for token: $connectorToken") Log.w(TAG, "No subscription ID found for token: $connectorToken")
onError("No subscription ID found") onError("No subscription ID found")
return return
} }

View file

@ -13,11 +13,8 @@ import androidx.lifecycle.viewModelScope
import app.lonecloud.prism.DatabaseFactory import app.lonecloud.prism.DatabaseFactory
import app.lonecloud.prism.EncryptionKeyStore import app.lonecloud.prism.EncryptionKeyStore
import app.lonecloud.prism.PrismPreferences import app.lonecloud.prism.PrismPreferences
import app.lonecloud.prism.PrismServerClient
import app.lonecloud.prism.activities.ui.InstalledApp import app.lonecloud.prism.activities.ui.InstalledApp
import app.lonecloud.prism.activities.ui.MainUiState import app.lonecloud.prism.activities.ui.MainUiState
import app.lonecloud.prism.api.MessageSender
import app.lonecloud.prism.api.data.ClientMessage
import app.lonecloud.prism.utils.TAG import app.lonecloud.prism.utils.TAG
import app.lonecloud.prism.utils.VapidKeyGenerator import app.lonecloud.prism.utils.VapidKeyGenerator
import app.lonecloud.prism.utils.WebPushEncryptionKeys import app.lonecloud.prism.utils.WebPushEncryptionKeys
@ -191,9 +188,15 @@ class MainViewModel(
val keyStore = EncryptionKeyStore(app) val keyStore = EncryptionKeyStore(app)
keyStore.storeKeys(channelId, encryptionKeys.privateKey, encryptionKeys.authBytes, encryptionKeys.p256dh) keyStore.storeKeys(channelId, encryptionKeys.privateKey, encryptionKeys.authBytes, encryptionKeys.p256dh)
val packageName = if (targetPackageName.isNotBlank()) {
targetPackageName
} else {
"app.lonecloud.prism.manual"
}
val db = DatabaseFactory.getDb(app) val db = DatabaseFactory.getDb(app)
db.registerApp( db.registerApp(
app.packageName, packageName,
connectorToken, connectorToken,
channelId, channelId,
name, name,
@ -201,41 +204,15 @@ class MainViewModel(
fullDescription fullDescription
) )
MessageSender.send( val intent = Intent("org.unifiedpush.android.distributor.REGISTER").apply {
app, `package` = app.packageName
ClientMessage.Register( putExtra("token", connectorToken)
channelID = channelId, putExtra("application", packageName)
key = vapidKeys.publicKey putExtra("message", name)
) }
) app.sendBroadcast(intent)
refreshRegistrations() refreshRegistrations()
var endpoint: String?
var attempts = 0
repeat(60) {
kotlinx.coroutines.delay(500)
attempts++
endpoint = db.getEndpoint(connectorToken)
if (attempts % 10 == 0) {
Log.d(TAG, "Polling attempt $attempts/60, endpoint: ${endpoint ?: "null"}")
}
if (endpoint != null) {
Log.d(TAG, "Endpoint received after $attempts attempts: $endpoint")
PrismServerClient.registerApp(
app,
connectorToken,
name,
endpoint!!,
vapidPrivateKey = vapidKeys.privateKey,
p256dh = encryptionKeys.p256dh,
auth = encryptionKeys.auth
)
return@launch
}
}
Log.e(TAG, "Endpoint timeout after 30 seconds for token: $connectorToken")
} }
} }
} }

View file

@ -43,7 +43,7 @@ fun AppPickerScreen(
val context = androidx.compose.ui.platform.LocalContext.current val context = androidx.compose.ui.platform.LocalContext.current
androidx.compose.runtime.LaunchedEffect(Unit) { androidx.compose.runtime.LaunchedEffect(Unit) {
kotlinx.coroutines.delay(100) kotlinx.coroutines.delay(200)
showContent = true showContent = true
} }

View file

@ -31,7 +31,11 @@ object MessageSender {
message.send(it) message.send(it)
} ?: run { } ?: run {
Log.d(TAG, "Msg not sent, will be during restart") Log.d(TAG, "Msg not sent, will be during restart")
try {
RestartWorker.run(context, delay = 0) RestartWorker.run(context, delay = 0)
} catch (e: IllegalStateException) {
Log.d(TAG, "WorkManager not available in this process, service will handle restart")
}
} }
} }
} }

View file

@ -19,13 +19,16 @@ import app.lonecloud.prism.Distributor
import app.lonecloud.prism.Distributor.sendMessage import app.lonecloud.prism.Distributor.sendMessage
import app.lonecloud.prism.EncryptionKeyStore import app.lonecloud.prism.EncryptionKeyStore
import app.lonecloud.prism.PrismPreferences import app.lonecloud.prism.PrismPreferences
import app.lonecloud.prism.PrismServerClient
import app.lonecloud.prism.R import app.lonecloud.prism.R
import app.lonecloud.prism.api.data.ClientMessage import app.lonecloud.prism.api.data.ClientMessage
import app.lonecloud.prism.api.data.NotificationPayload
import app.lonecloud.prism.api.data.ServerMessage import app.lonecloud.prism.api.data.ServerMessage
import app.lonecloud.prism.callback.NetworkCallbackFactory import app.lonecloud.prism.callback.NetworkCallbackFactory
import app.lonecloud.prism.services.FgService import app.lonecloud.prism.services.FgService
import app.lonecloud.prism.services.RestartWorker import app.lonecloud.prism.services.RestartWorker
import app.lonecloud.prism.services.SourceManager import app.lonecloud.prism.services.SourceManager
import app.lonecloud.prism.utils.ManualAppNotifications
import app.lonecloud.prism.utils.TAG import app.lonecloud.prism.utils.TAG
import app.lonecloud.prism.utils.WebPushDecryptor import app.lonecloud.prism.utils.WebPushDecryptor
import java.util.Calendar import java.util.Calendar
@ -133,11 +136,14 @@ class ServerConnection(private val context: Context, private val releaseLock: ()
val encryptedData = Base64.decode(message.data, Base64.URL_SAFE) val encryptedData = Base64.decode(message.data, Base64.URL_SAFE)
val db = DatabaseFactory.getDb(context) val db = DatabaseFactory.getDb(context)
val allApps = db.listApps() val vapidKey = db.listChannelIdVapid()
val app = allApps.find { appEntry -> .find { (channelId, _) -> channelId == message.channelID }
db.listChannelIdVapid().any { (channelId, _) -> ?.second
channelId == message.channelID && appEntry.description?.startsWith("target:") == true
} val app = if (vapidKey != null) {
db.listApps().find { it.vapidKey == vapidKey && it.description?.startsWith("target:") == true }
} else {
null
} }
val decryptedData = if (app != null) { val decryptedData = if (app != null) {
@ -173,11 +179,26 @@ class ServerConnection(private val context: Context, private val releaseLock: ()
encryptedData encryptedData
} }
sendMessage( if (app != null) {
val dataString = String(decryptedData, Charsets.UTF_8)
val payload = NotificationPayload.fromJson(dataString)
if (payload != null) {
Log.d(TAG, "Displaying notification for manual app '${app.title}': ${payload.title}")
ManualAppNotifications.showNotification(
context, context,
message.channelID, message.channelID,
decryptedData app,
payload
) )
} else {
Log.w(TAG, "Failed to parse notification payload for manual app, falling back to sendMessage")
sendMessage(context, message.channelID, decryptedData)
}
} else {
sendMessage(context, message.channelID, decryptedData)
}
ClientMessage.Ack( ClientMessage.Ack(
arrayOf(ClientMessage.ClientAck(message.channelID, message.version)) arrayOf(ClientMessage.ClientAck(message.channelID, message.version))
).send(webSocket) ).send(webSocket)
@ -199,6 +220,59 @@ class ServerConnection(private val context: Context, private val releaseLock: ()
context, context,
ChannelCreationStatus.Ok(message.channelID, message.pushEndpoint) ChannelCreationStatus.Ok(message.channelID, message.pushEndpoint)
) )
val db = DatabaseFactory.getDb(context)
val vapidKey = db.listChannelIdVapid()
.find { (channelId, _) -> channelId == message.channelID }
?.second
val app = if (vapidKey != null) {
db.listApps().find { it.vapidKey == vapidKey && it.description?.startsWith("target:") == true }
} else {
null
}
if (app != null) {
Log.d(TAG, "Auto-registering manual app '${app.title}' with Prism server")
val keyStore = EncryptionKeyStore(context)
val keys = keyStore.getKeys(message.channelID)
PrismServerClient.registerApp(
context,
app.connectorToken,
app.title ?: "Unknown App",
message.pushEndpoint,
vapidPrivateKey = app.vapidKey,
p256dh = keys?.third,
auth = android.util.Base64.encodeToString(
keys?.second,
android.util.Base64.URL_SAFE or android.util.Base64.NO_PADDING or android.util.Base64.NO_WRAP
),
onSuccess = {
Log.d(TAG, "Successfully registered '${app.title}' with Prism server")
},
onError = { error ->
Log.e(TAG, "Failed to register '${app.title}' with Prism server: $error")
Distributor.deleteApp(context, app.connectorToken)
MessageSender.send(
context,
ClientMessage.Unregister(channelID = message.channelID)
)
Handler(Looper.getMainLooper()).post {
Toast.makeText(
context,
"Failed to register ${app.title}: $error",
Toast.LENGTH_LONG
).show()
}
}
)
} else {
Log.d(TAG, "Not a manual app or app not found for channelId: ${message.channelID}")
}
} }
private fun onUnregister(message: ServerMessage.Unregister) { private fun onUnregister(message: ServerMessage.Unregister) {

View file

@ -0,0 +1,60 @@
package app.lonecloud.prism.api.data
import org.json.JSONArray
import org.json.JSONObject
data class NotificationPayload(
val title: String,
val message: String,
val tag: String,
val actions: List<NotificationAction>
) {
companion object {
fun fromJson(jsonString: String): NotificationPayload? = try {
val json = JSONObject(jsonString)
val title = json.optString("Title", "")
val message = json.optString("Message", "")
val tag = json.optString("Tag", "")
val actionsArray = json.optJSONArray("Actions") ?: JSONArray()
val actions = mutableListOf<NotificationAction>()
for (i in 0 until actionsArray.length()) {
val actionObj = actionsArray.getJSONObject(i)
val action = NotificationAction(
id = actionObj.optString("ID", ""),
label = actionObj.optString("Label", ""),
endpoint = actionObj.optString("Endpoint", ""),
method = actionObj.optString("Method", "POST"),
data = parseDataMap(actionObj.optJSONObject("Data"))
)
actions.add(action)
}
NotificationPayload(title, message, tag, actions)
} catch (e: Exception) {
null
}
private fun parseDataMap(dataObj: JSONObject?): Map<String, Any> {
if (dataObj == null) return emptyMap()
val map = mutableMapOf<String, Any>()
val keys = dataObj.keys()
while (keys.hasNext()) {
val key = keys.next()
val value = dataObj.get(key)
map[key] = value
}
return map
}
}
}
data class NotificationAction(
val id: String,
val label: String,
val endpoint: String,
val method: String,
val data: Map<String, Any>
)

View file

@ -0,0 +1,100 @@
package app.lonecloud.prism.receivers
import android.app.NotificationManager
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import android.util.Log
import app.lonecloud.prism.PrismPreferences
import app.lonecloud.prism.utils.TAG
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.RequestBody.Companion.toRequestBody
import org.json.JSONObject
class NotificationActionReceiver : BroadcastReceiver() {
override fun onReceive(context: Context, intent: Intent) {
val channelID = intent.getStringExtra("channelID") ?: return
val actionID = intent.getStringExtra("actionID") ?: return
val actionLabel = intent.getStringExtra("actionLabel") ?: ""
val actionEndpoint = intent.getStringExtra("actionEndpoint") ?: return
val actionMethod = intent.getStringExtra("actionMethod") ?: "POST"
val notificationTag = intent.getStringExtra("notificationTag") ?: ""
val notificationId = intent.getIntExtra("notificationId", -1)
val data = mutableMapOf<String, String>()
intent.extras?.keySet()?.forEach { key ->
if (key.startsWith("data_")) {
val dataKey = key.substring(5)
val value = intent.getStringExtra(key)
if (value != null) {
data[dataKey] = value
}
}
}
Log.d(TAG, "Notification action triggered: $actionLabel ($actionID) for channel $channelID")
CoroutineScope(Dispatchers.IO).launch {
try {
executeAction(context, actionEndpoint, actionMethod, data)
if (notificationTag.isNotEmpty() && notificationId != -1) {
val notificationManager = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
notificationManager.cancel(notificationTag, notificationId)
Log.d(TAG, "Dismissed notification with tag: $notificationTag")
}
} catch (e: Exception) {
Log.e(TAG, "Failed to execute notification action: ${e.message}", e)
}
}
}
private fun executeAction(
context: Context,
endpoint: String,
method: String,
data: Map<String, String>
) {
val prefs = PrismPreferences(context)
val serverUrl = prefs.prismServerUrl ?: run {
Log.e(TAG, "No Prism server URL configured")
return
}
val apiKey = prefs.prismApiKey ?: run {
Log.e(TAG, "No Prism API key configured")
return
}
val fullUrl = if (endpoint.startsWith("http")) {
endpoint
} else {
serverUrl.trimEnd('/') + "/" + endpoint.trimStart('/')
}
val jsonBody = JSONObject(data as Map<*, *>).toString()
val requestBody = jsonBody.toRequestBody("application/json".toMediaType())
val request = Request.Builder()
.url(fullUrl)
.method(method, if (method == "GET" || method == "HEAD") null else requestBody)
.addHeader("Authorization", "Bearer $apiKey")
.addHeader("Content-Type", "application/json")
.build()
Log.d(TAG, "Executing action: $method $fullUrl with data: $jsonBody")
OkHttpClient().newCall(request).execute().use { response ->
if (response.isSuccessful) {
Log.d(TAG, "Action executed successfully: ${response.code}")
} else {
Log.e(TAG, "Action failed: ${response.code} ${response.message}")
}
}
}
}

View file

@ -75,7 +75,7 @@ class PrismInternalService : InternalService() {
null null
} }
val packageToResolve = (targetPackage ?: it.packageName) ?: "" val packageToResolve = targetPackage ?: it.packageName
val appName = try { val appName = try {
val appInfo = pm.getApplicationInfo(packageToResolve, PackageManager.GET_META_DATA) val appInfo = pm.getApplicationInfo(packageToResolve, PackageManager.GET_META_DATA)
pm.getApplicationLabel(appInfo).toString() pm.getApplicationLabel(appInfo).toString()
@ -92,11 +92,7 @@ class PrismInternalService : InternalService() {
vapidKey = it.vapidKey, vapidKey = it.vapidKey,
title = displayTitle, title = displayTitle,
msgCount = it.msgCount, msgCount = it.msgCount,
description = if (it.packageName == this@PrismInternalService.packageName) { description = Description.StringDescription(packageToResolve),
Description.LocalChannel
} else {
Description.StringDescription(packageToResolve)
},
icon = getApplicationIcon(packageToResolve)?.toBitmap(), icon = getApplicationIcon(packageToResolve)?.toBitmap(),
isLocal = it.packageName == this@PrismInternalService.packageName isLocal = it.packageName == this@PrismInternalService.packageName
) )

View file

@ -0,0 +1,173 @@
package app.lonecloud.prism.utils
import android.app.NotificationChannel
import android.app.NotificationManager
import android.app.PendingIntent
import android.content.Context
import android.content.Intent
import android.util.Log
import androidx.core.app.NotificationCompat
import app.lonecloud.prism.R
import app.lonecloud.prism.api.data.NotificationAction
import app.lonecloud.prism.api.data.NotificationPayload
import app.lonecloud.prism.receivers.NotificationActionReceiver
import app.lonecloud.prism.services.MainRegistrationCounter
import org.unifiedpush.android.distributor.Database
private const val NOTIFICATION_BASE_ID = 52000
object ManualAppNotifications {
private val notificationIds = mutableMapOf<String, Int>()
private var nextNotificationId = NOTIFICATION_BASE_ID
fun showNotification(
context: Context,
channelID: String,
app: Database.App,
payload: NotificationPayload
) {
if (payload.title.isBlank() && payload.message.isBlank()) {
dismissNotification(context, payload.tag)
return
}
val channelId = "manual_app_${app.connectorToken}"
val appTitle = app.title ?: "Unknown App"
createNotificationChannel(context, channelId, appTitle)
val notificationId = getNotificationId(payload.tag)
val packageName = extractTargetPackage(app.description)
val notificationBuilder = NotificationCompat.Builder(context, channelId)
.setSmallIcon(R.drawable.ic_notification)
.setContentTitle(payload.title)
.setContentText(payload.message)
.setPriority(NotificationCompat.PRIORITY_HIGH)
.setAutoCancel(true)
.setGroup(app.connectorToken)
val contentIntent = createContentIntent(context, packageName, notificationId)
if (contentIntent != null) {
notificationBuilder.setContentIntent(contentIntent)
}
payload.actions.take(3).forEach { action ->
val actionIntent = createActionIntent(
context,
channelID,
action,
payload.tag,
notificationId
)
notificationBuilder.addAction(
0,
action.label,
actionIntent
)
}
val notificationManager = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
notificationManager.notify(payload.tag, notificationId, notificationBuilder.build())
updateMessageCount(context)
Log.d(TAG, "Displayed notification for manual app '${app.title}': ${payload.title} (tag: ${payload.tag})")
}
fun dismissNotification(context: Context, tag: String) {
val notificationId = notificationIds[tag]
if (notificationId != null) {
val notificationManager = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
notificationManager.cancel(tag, notificationId)
notificationIds.remove(tag)
Log.d(TAG, "Dismissed notification with tag: $tag")
} else {
Log.w(TAG, "Cannot dismiss notification - tag not found: $tag")
}
}
private fun createNotificationChannel(
context: Context,
channelId: String,
appName: String
) {
val channel = NotificationChannel(
channelId,
appName,
NotificationManager.IMPORTANCE_HIGH
).apply {
enableVibration(true)
}
val notificationManager = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
notificationManager.createNotificationChannel(channel)
}
private fun getNotificationId(tag: String): Int = notificationIds.getOrPut(tag) {
nextNotificationId++
}
private fun extractTargetPackage(description: String?): String? {
if (description == null || !description.startsWith("target:")) return null
return description.substringAfter("target:").substringBefore("|").takeIf { it.isNotBlank() }
}
private fun createContentIntent(
context: Context,
packageName: String?,
notificationId: Int
): PendingIntent? {
if (packageName == null) return null
val pm = context.packageManager
val launchIntent = pm.getLaunchIntentForPackage(packageName)
return if (launchIntent != null) {
launchIntent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
PendingIntent.getActivity(
context,
notificationId,
launchIntent,
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
)
} else {
Log.w(TAG, "Could not create launch intent for package: $packageName")
null
}
}
private fun createActionIntent(
context: Context,
channelID: String,
action: NotificationAction,
notificationTag: String,
notificationId: Int
): PendingIntent {
val intent = Intent(context, NotificationActionReceiver::class.java).apply {
putExtra("channelID", channelID)
putExtra("actionID", action.id)
putExtra("actionLabel", action.label)
putExtra("actionEndpoint", action.endpoint)
putExtra("actionMethod", action.method)
putExtra("notificationTag", notificationTag)
putExtra("notificationId", notificationId)
action.data.forEach { (key, value) ->
putExtra("data_$key", value.toString())
}
}
val requestCode = (channelID + action.id).hashCode()
return PendingIntent.getBroadcast(
context,
requestCode,
intent,
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
)
}
private fun updateMessageCount(context: Context) {
MainRegistrationCounter.onCountRefreshed(context)
}
}

View file

@ -14,33 +14,33 @@
<string name="warning_notif_ticker">Prism Warning</string> <string name="warning_notif_ticker">Prism Warning</string>
<string name="foreground_service">Foreground Service</string> <string name="foreground_service">Foreground Service</string>
<string name="foreground_notif_description">Notification to run in the foreground</string> <string name="foreground_notif_description">Notification to run in the foreground</string>
<string name="foreground_notif_content_no_reg">Waiting for registration to connect</string> <string name="foreground_notif_content_no_reg">Waiting for apps to register</string>
<string name="foreground_notif_ticker">Prism</string> <string name="foreground_notif_ticker">Prism</string>
<string name="bar_unregister_title">%d selected</string> <string name="bar_unregister_title">%d selected</string>
<string name="dialog_unregistering_content">Are you sure to delete this registration?</string> <string name="dialog_unregistering_content">Are you sure to unregister this app?</string>
<plurals name="bar_unregister_title"> <plurals name="bar_unregister_title">
<item quantity="one">%d selected</item> <item quantity="one">%d selected</item>
<item quantity="other">%d selected</item> <item quantity="other">%d selected</item>
</plurals> </plurals>
<plurals name="dialog_unregistering_content"> <plurals name="dialog_unregistering_content">
<item quantity="one">Are you sure to delete this registration?</item> <item quantity="one">Are you sure to unregister this app?</item>
<item quantity="other">Are you sure to delete %d registrations?</item> <item quantity="other">Are you sure to unregister %d apps?</item>
</plurals> </plurals>
<plurals name="foreground_notif_content_with_reg"> <plurals name="foreground_notif_content_with_reg">
<item quantity="one">Connected for %d registration</item> <item quantity="one">Connected for %d app</item>
<item quantity="other">Connected for %d registrations</item> <item quantity="other">Connected for %d apps</item>
</plurals> </plurals>
<!-- Prism-specific strings --> <!-- Prism-specific strings -->
<string name="app_name">Prism</string> <string name="app_name">Prism</string>
<string name="settings">Settings</string> <string name="settings">Settings</string>
<string name="add_custom_app_title">Add New App</string> <string name="add_custom_app_title">Register a New App</string>
<string name="add_app_description">Add an app to receive notifications from your Prism server. Optionally choose a target app to deliver notifications to.</string> <string name="add_app_description">Register an app to receive notifications from your Prism server. Optionally choose a target app to deliver notifications to.</string>
<string name="app_name_label">App Name</string> <string name="app_name_label">App Name</string>
<string name="app_name_placeholder">Enter app name</string> <string name="app_name_placeholder">Enter app name</string>
<string name="target_app_label">Target App (Optional)</string> <string name="target_app_label">Target App (Optional)</string>
<string name="select_an_app">Select an app</string> <string name="select_an_app">Select an app</string>
<string name="add_button">Add</string> <string name="add_button">Register</string>
<string name="cancel_button">Cancel</string> <string name="cancel_button">Cancel</string>
<string name="select_target_app_title">Select Target App</string> <string name="select_target_app_title">Select Target App</string>
<string name="recommended_apps">Recommended</string> <string name="recommended_apps">Recommended</string>
@ -48,11 +48,11 @@
<string name="all_apps">All Apps</string> <string name="all_apps">All Apps</string>
<string name="search_apps_label">Search</string> <string name="search_apps_label">Search</string>
<string name="search_apps_placeholder">Search for apps</string> <string name="search_apps_placeholder">Search for apps</string>
<string name="add_manual_app_content_description">Add manual app</string> <string name="add_manual_app_content_description">Register a new app</string>
<string name="registered_apps_heading">Registered Apps</string> <string name="registered_apps_heading">Registered Apps</string>
<string name="debug_title">Debug Information</string> <string name="debug_title">Debug Information</string>
<string name="configure_server">Configure Prism server</string> <string name="configure_server">Configure a Prism server</string>
<string name="prism_server_info">Prism server enables manual app registrations and must be self-hosted.</string> <string name="prism_server_info">Prism server lets you receive notifications from custom services and must be self-hosted.</string>
<string name="prism_server_learn_more">Learn more about Prism</string> <string name="prism_server_learn_more">Learn more about Prism</string>
<string name="prism_server_configured">Prism server configured</string> <string name="prism_server_configured">Prism server configured</string>
<string name="prism_server_configured_with_version">%1$s (v%2$s)</string> <string name="prism_server_configured_with_version">%1$s (v%2$s)</string>
@ -68,7 +68,7 @@
<string name="clear_server_button">Remove</string> <string name="clear_server_button">Remove</string>
<string name="clear_server_confirm_title">Remove Prism Server?</string> <string name="clear_server_confirm_title">Remove Prism Server?</string>
<string name="clear_server_confirm_message_no_apps">This will remove your Prism server configuration.</string> <string name="clear_server_confirm_message_no_apps">This will remove your Prism server configuration.</string>
<string name="clear_server_confirm_message_with_apps">You have %d manual app registration(s). Clearing the server will delete them from the server and remove the configuration.</string> <string name="clear_server_confirm_message_with_apps">You have %d manual app(s). Clearing the server will unregister them from the server and remove the configuration.</string>
<string name="app_dropdown_show_toasts">Notify when apps register</string> <string name="app_dropdown_show_toasts">Notify when apps register</string>
<string name="show_toasts_description">Show a notification when apps register or unregister</string> <string name="show_toasts_description">Show a notification when apps register or unregister</string>
<string name="dynamic_colors_title">Dynamic colors</string> <string name="dynamic_colors_title">Dynamic colors</string>
@ -76,7 +76,7 @@
<!-- Intro screen strings --> <!-- Intro screen strings -->
<string name="intro_welcome_title">Welcome to Prism</string> <string name="intro_welcome_title">Welcome to Prism</string>
<string name="intro_welcome_message">Prism is a UnifiedPush distributor that supports manual app registrations through an optional Prism server.</string> <string name="intro_welcome_message">Prism handles push notifications for your apps. Connect a Prism server to also receive notifications from custom services.</string>
<string name="intro_server_optional">Configure a Prism server now or skip to set it up later in Settings.</string> <string name="intro_server_optional">Configure a Prism server now or skip to set it up later in Settings.</string>
<string name="intro_continue_button">Continue</string> <string name="intro_continue_button">Continue</string>
<string name="intro_skip_button">Skip for now</string> <string name="intro_skip_button">Skip for now</string>