mirror of
https://github.com/lone-cloud/prism-android
synced 2026-06-03 11:03:10 -07:00
WIP: manual app notifications
This commit is contained in:
parent
1de7918203
commit
bbce35a667
13 changed files with 521 additions and 83 deletions
1
.github/copilot-instructions.md
vendored
1
.github/copilot-instructions.md
vendored
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
@ -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 }
|
|
||||||
if (app?.description?.startsWith("target:") == true) {
|
val channelVapidPair = db.listChannelIdVapid().find { it.first == channelId }
|
||||||
PrismServerClient.deleteApp(context, 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) {
|
||||||
|
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(
|
||||||
|
|
|
||||||
|
|
@ -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()
|
||||||
val responseJson = JSONObject(responseBody)
|
|
||||||
val subscriptionId = responseJson.getLong("id")
|
|
||||||
|
|
||||||
store.setSubscriptionId(connectorToken, subscriptionId)
|
try {
|
||||||
|
val responseJson = JSONObject(responseBody)
|
||||||
|
val subscriptionId = responseJson.getString("subscriptionId").toLong()
|
||||||
|
|
||||||
Log.d(TAG, "Successfully registered app: $appName (subscription ID: $subscriptionId)")
|
store.setSubscriptionId(connectorToken, subscriptionId)
|
||||||
withContext(Dispatchers.Main) { onSuccess() }
|
|
||||||
|
Log.d(TAG, "Successfully registered app: $appName (ID: $subscriptionId)")
|
||||||
|
withContext(Dispatchers.Main) { onSuccess() }
|
||||||
|
} catch (e: Exception) {
|
||||||
|
val error = "Failed to parse registration response: ${e.message}"
|
||||||
|
Log.e(TAG, error)
|
||||||
|
Log.e(TAG, "Response body: $responseBody")
|
||||||
|
withContext(Dispatchers.Main) { onError(error) }
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
|
val responseBody = response.body.string()
|
||||||
val error = "Failed to register app: ${response.code} ${response.message}"
|
val error = "Failed to register app: ${response.code} ${response.message}"
|
||||||
Log.e(TAG, error)
|
Log.e(TAG, "$error - Response: $responseBody")
|
||||||
withContext(Dispatchers.Main) { onError(error) }
|
withContext(Dispatchers.Main) { onError(error) }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -110,12 +120,30 @@ 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) {
|
||||||
|
|
@ -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
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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")
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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")
|
||||||
RestartWorker.run(context, delay = 0)
|
try {
|
||||||
|
RestartWorker.run(context, delay = 0)
|
||||||
|
} catch (e: IllegalStateException) {
|
||||||
|
Log.d(TAG, "WorkManager not available in this process, service will handle restart")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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) {
|
||||||
context,
|
val dataString = String(decryptedData, Charsets.UTF_8)
|
||||||
message.channelID,
|
val payload = NotificationPayload.fromJson(dataString)
|
||||||
decryptedData
|
|
||||||
)
|
if (payload != null) {
|
||||||
|
Log.d(TAG, "Displaying notification for manual app '${app.title}': ${payload.title}")
|
||||||
|
ManualAppNotifications.showNotification(
|
||||||
|
context,
|
||||||
|
message.channelID,
|
||||||
|
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) {
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
)
|
||||||
|
|
@ -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}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue