mirror of
https://github.com/lone-cloud/prism-android
synced 2026-06-03 19:54:44 -07:00
add webpush support for manually added apps - now requires for the prism server to be pre-configured, unregister apps on delete or server change
This commit is contained in:
parent
5184aefcaf
commit
fda60f3c77
17 changed files with 543 additions and 29 deletions
|
|
@ -98,6 +98,7 @@ android {
|
||||||
dependencies {
|
dependencies {
|
||||||
implementation(libs.unifiedpush.distributor)
|
implementation(libs.unifiedpush.distributor)
|
||||||
implementation(libs.unifiedpush.distributor.ui)
|
implementation(libs.unifiedpush.distributor.ui)
|
||||||
|
implementation(libs.tink.android)
|
||||||
implementation(libs.androidx.activity.compose)
|
implementation(libs.androidx.activity.compose)
|
||||||
implementation(libs.androidx.lifecycle.viewmodel.compose)
|
implementation(libs.androidx.lifecycle.viewmodel.compose)
|
||||||
implementation(libs.androidx.work.runtime.ktx)
|
implementation(libs.androidx.work.runtime.ktx)
|
||||||
|
|
|
||||||
|
|
@ -27,7 +27,7 @@ object Distributor : UnifiedPushDistributor() {
|
||||||
context,
|
context,
|
||||||
ClientMessage.Register(
|
ClientMessage.Register(
|
||||||
channelID = uuid,
|
channelID = uuid,
|
||||||
key = null
|
key = vapid
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
64
app/src/main/java/app/lonecloud/prism/EncryptionKeyStore.kt
Normal file
64
app/src/main/java/app/lonecloud/prism/EncryptionKeyStore.kt
Normal file
|
|
@ -0,0 +1,64 @@
|
||||||
|
package app.lonecloud.prism
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import android.content.SharedPreferences
|
||||||
|
import android.util.Base64
|
||||||
|
import androidx.core.content.edit
|
||||||
|
|
||||||
|
class EncryptionKeyStore(context: Context) {
|
||||||
|
private val sharedPreferences: SharedPreferences = context.getSharedPreferences(
|
||||||
|
PREF_NAME,
|
||||||
|
Context.MODE_PRIVATE
|
||||||
|
)
|
||||||
|
|
||||||
|
fun storeKeys(
|
||||||
|
channelId: String,
|
||||||
|
privateKey: ByteArray,
|
||||||
|
authSecret: ByteArray,
|
||||||
|
publicKey: String
|
||||||
|
) {
|
||||||
|
sharedPreferences.edit {
|
||||||
|
putString(keyFor(channelId, KEY_PRIVATE), base64Encode(privateKey))
|
||||||
|
putString(keyFor(channelId, KEY_AUTH), base64Encode(authSecret))
|
||||||
|
putString(keyFor(channelId, KEY_PUBLIC), publicKey)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun getKeys(channelId: String): Triple<ByteArray, ByteArray, String>? {
|
||||||
|
val privateKeyB64 = sharedPreferences.getString(keyFor(channelId, KEY_PRIVATE), null)
|
||||||
|
val authSecretB64 = sharedPreferences.getString(keyFor(channelId, KEY_AUTH), null)
|
||||||
|
val publicKey = sharedPreferences.getString(keyFor(channelId, KEY_PUBLIC), null)
|
||||||
|
|
||||||
|
return if (privateKeyB64 != null && authSecretB64 != null && publicKey != null) {
|
||||||
|
Triple(base64Decode(privateKeyB64), base64Decode(authSecretB64), publicKey)
|
||||||
|
} else {
|
||||||
|
null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun deleteKeys(channelId: String) {
|
||||||
|
sharedPreferences.edit {
|
||||||
|
remove(keyFor(channelId, KEY_PRIVATE))
|
||||||
|
remove(keyFor(channelId, KEY_AUTH))
|
||||||
|
remove(keyFor(channelId, KEY_PUBLIC))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun hasKeys(channelId: String): Boolean = sharedPreferences.contains(keyFor(channelId, KEY_PRIVATE)) &&
|
||||||
|
sharedPreferences.contains(keyFor(channelId, KEY_AUTH)) &&
|
||||||
|
sharedPreferences.contains(keyFor(channelId, KEY_PUBLIC))
|
||||||
|
|
||||||
|
private fun keyFor(channelId: String, suffix: String): String = "$PREF_PREFIX$channelId$suffix"
|
||||||
|
|
||||||
|
private fun base64Encode(data: ByteArray): String = Base64.encodeToString(data, Base64.NO_WRAP)
|
||||||
|
|
||||||
|
private fun base64Decode(data: String): ByteArray = Base64.decode(data, Base64.NO_WRAP)
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
private const val PREF_NAME = "WebPushEncryptionKeys"
|
||||||
|
private const val PREF_PREFIX = "channel_"
|
||||||
|
private const val KEY_PRIVATE = "_private"
|
||||||
|
private const val KEY_AUTH = "_auth"
|
||||||
|
private const val KEY_PUBLIC = "_public"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
package app.lonecloud.prism.sup
|
package app.lonecloud.prism
|
||||||
|
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.util.Base64
|
import android.util.Base64
|
||||||
|
|
@ -32,7 +32,10 @@ object PrismServerClient {
|
||||||
fun registerApp(
|
fun registerApp(
|
||||||
context: Context,
|
context: Context,
|
||||||
appName: String,
|
appName: String,
|
||||||
upEndpoint: String,
|
webpushUrl: String,
|
||||||
|
vapidPrivateKey: String? = null,
|
||||||
|
p256dh: String? = null,
|
||||||
|
auth: String? = null,
|
||||||
onSuccess: () -> Unit = {},
|
onSuccess: () -> Unit = {},
|
||||||
onError: (String) -> Unit = {}
|
onError: (String) -> Unit = {}
|
||||||
) {
|
) {
|
||||||
|
|
@ -49,17 +52,20 @@ object PrismServerClient {
|
||||||
try {
|
try {
|
||||||
val json = JSONObject().apply {
|
val json = JSONObject().apply {
|
||||||
put("appName", appName)
|
put("appName", appName)
|
||||||
put("upEndpoint", upEndpoint)
|
put("webpushUrl", webpushUrl)
|
||||||
|
vapidPrivateKey?.let { put("vapidPrivateKey", it) }
|
||||||
|
p256dh?.let { put("p256dh", it) }
|
||||||
|
auth?.let { put("auth", it) }
|
||||||
}
|
}
|
||||||
|
|
||||||
val request = Request.Builder()
|
val request = Request.Builder()
|
||||||
.url("$serverUrl/api/webhook/register")
|
.url("$serverUrl/webpush/app")
|
||||||
.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 sup server: $appName -> $upEndpoint")
|
Log.d(TAG, "Registering app with Prism server: $appName -> $webpushUrl")
|
||||||
|
|
||||||
client.newCall(request).execute().use { response ->
|
client.newCall(request).execute().use { response ->
|
||||||
if (response.isSuccessful) {
|
if (response.isSuccessful) {
|
||||||
|
|
@ -96,7 +102,7 @@ object PrismServerClient {
|
||||||
|
|
||||||
val manualApps = apps.filter { it.description?.startsWith("target:") == true }
|
val manualApps = apps.filter { it.description?.startsWith("target:") == true }
|
||||||
|
|
||||||
Log.d(TAG, "Registering ${manualApps.size} manual apps with sup server")
|
Log.d(TAG, "Registering ${manualApps.size} manual apps with Prism server")
|
||||||
|
|
||||||
manualApps.forEach { app ->
|
manualApps.forEach { app ->
|
||||||
app.endpoint?.let { endpoint ->
|
app.endpoint?.let { endpoint ->
|
||||||
|
|
@ -114,6 +120,84 @@ object PrismServerClient {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun deleteApp(
|
||||||
|
context: Context,
|
||||||
|
appName: String,
|
||||||
|
serverUrl: String? = null,
|
||||||
|
apiKey: String? = null,
|
||||||
|
onSuccess: () -> Unit = {},
|
||||||
|
onError: (String) -> Unit = {}
|
||||||
|
) {
|
||||||
|
val store = AppStore(context)
|
||||||
|
val url = serverUrl ?: store.prismServerUrl
|
||||||
|
val key = apiKey ?: store.prismApiKey
|
||||||
|
|
||||||
|
if (url.isNullOrBlank() || key.isNullOrBlank()) {
|
||||||
|
Log.d(TAG, "Prism server not configured, skipping deletion")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
CoroutineScope(Dispatchers.IO).launch {
|
||||||
|
try {
|
||||||
|
val request = Request.Builder()
|
||||||
|
.url("$url/webpush/app/$appName")
|
||||||
|
.addHeader("Authorization", getAuthHeader(key))
|
||||||
|
.delete()
|
||||||
|
.build()
|
||||||
|
|
||||||
|
Log.d(TAG, "Deleting app from Prism server: $appName")
|
||||||
|
|
||||||
|
client.newCall(request).execute().use { response ->
|
||||||
|
if (response.isSuccessful) {
|
||||||
|
Log.d(TAG, "Successfully deleted app: $appName")
|
||||||
|
onSuccess()
|
||||||
|
} else {
|
||||||
|
val error = "Failed to delete app: ${response.code} ${response.message}"
|
||||||
|
Log.e(TAG, error)
|
||||||
|
onError(error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e: IOException) {
|
||||||
|
val error = "Error deleting app: ${e.message}"
|
||||||
|
Log.e(TAG, error, e)
|
||||||
|
onError(error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun deleteAllApps(
|
||||||
|
context: Context,
|
||||||
|
serverUrl: String? = null,
|
||||||
|
apiKey: String? = null
|
||||||
|
) {
|
||||||
|
val store = AppStore(context)
|
||||||
|
val url = serverUrl ?: store.prismServerUrl
|
||||||
|
val key = apiKey ?: store.prismApiKey
|
||||||
|
|
||||||
|
if (url.isNullOrBlank() || key.isNullOrBlank()) {
|
||||||
|
Log.d(TAG, "Prism server not configured, skipping bulk deletion")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
CoroutineScope(Dispatchers.IO).launch {
|
||||||
|
try {
|
||||||
|
val db = DatabaseFactory.getDb(context)
|
||||||
|
val apps = db.listApps()
|
||||||
|
|
||||||
|
val manualApps = apps.filter { it.description?.startsWith("target:") == true }
|
||||||
|
|
||||||
|
Log.d(TAG, "Deleting ${manualApps.size} manual apps from Prism server")
|
||||||
|
|
||||||
|
manualApps.forEach { app ->
|
||||||
|
val appName = app.title ?: app.packageName
|
||||||
|
deleteApp(context, appName, url, key)
|
||||||
|
}
|
||||||
|
} catch (e: IOException) {
|
||||||
|
Log.e(TAG, "Error during bulk deletion: ${e.message}", e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fun testConnection(
|
fun testConnection(
|
||||||
serverUrl: String,
|
serverUrl: String,
|
||||||
apiKey: String,
|
apiKey: String,
|
||||||
|
|
@ -5,8 +5,10 @@ import android.util.Log
|
||||||
import androidx.lifecycle.ViewModel
|
import androidx.lifecycle.ViewModel
|
||||||
import androidx.lifecycle.viewModelScope
|
import androidx.lifecycle.viewModelScope
|
||||||
import app.lonecloud.prism.AppStore
|
import app.lonecloud.prism.AppStore
|
||||||
|
import app.lonecloud.prism.DatabaseFactory
|
||||||
import app.lonecloud.prism.Distributor
|
import app.lonecloud.prism.Distributor
|
||||||
import app.lonecloud.prism.EventBus
|
import app.lonecloud.prism.EventBus
|
||||||
|
import app.lonecloud.prism.PrismServerClient
|
||||||
import app.lonecloud.prism.services.FgService
|
import app.lonecloud.prism.services.FgService
|
||||||
import app.lonecloud.prism.services.MigrationManager
|
import app.lonecloud.prism.services.MigrationManager
|
||||||
import app.lonecloud.prism.services.RestartWorker
|
import app.lonecloud.prism.services.RestartWorker
|
||||||
|
|
@ -52,8 +54,16 @@ class AppAction(private val action: Action) {
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun deleteRegistration(context: Context, action: Action.DeleteRegistration) {
|
private fun deleteRegistration(context: Context, action: Action.DeleteRegistration) {
|
||||||
action.registrations.forEach {
|
action.registrations.forEach { token ->
|
||||||
Distributor.deleteApp(context, it)
|
val db = DatabaseFactory.getDb(context)
|
||||||
|
val dbApp = db.listApps().find { it.connectorToken == token }
|
||||||
|
|
||||||
|
if (dbApp?.description?.startsWith("target:") == true) {
|
||||||
|
val appName = dbApp.title ?: dbApp.packageName
|
||||||
|
PrismServerClient.deleteApp(context, appName)
|
||||||
|
}
|
||||||
|
|
||||||
|
Distributor.deleteApp(context, token)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -85,7 +95,7 @@ class AppAction(private val action: Action) {
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun registerPrismServer(context: Context) {
|
private fun registerPrismServer(context: Context) {
|
||||||
app.lonecloud.prism.sup.PrismServerClient.registerAllApps(context)
|
app.lonecloud.prism.PrismServerClient.registerAllApps(context)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -12,13 +12,17 @@ import androidx.compose.runtime.mutableStateOf
|
||||||
import androidx.compose.runtime.setValue
|
import androidx.compose.runtime.setValue
|
||||||
import androidx.lifecycle.ViewModel
|
import androidx.lifecycle.ViewModel
|
||||||
import androidx.lifecycle.viewModelScope
|
import androidx.lifecycle.viewModelScope
|
||||||
|
import app.lonecloud.prism.AppStore
|
||||||
import app.lonecloud.prism.DatabaseFactory
|
import app.lonecloud.prism.DatabaseFactory
|
||||||
|
import app.lonecloud.prism.EncryptionKeyStore
|
||||||
|
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.MessageSender
|
||||||
import app.lonecloud.prism.api.data.ClientMessage
|
import app.lonecloud.prism.api.data.ClientMessage
|
||||||
import app.lonecloud.prism.sup.PrismServerClient
|
|
||||||
import app.lonecloud.prism.utils.TAG
|
import app.lonecloud.prism.utils.TAG
|
||||||
|
import app.lonecloud.prism.utils.VapidKeyGenerator
|
||||||
|
import app.lonecloud.prism.utils.WebPushEncryptionKeys
|
||||||
import java.util.UUID
|
import java.util.UUID
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import org.unifiedpush.android.distributor.ui.compose.BatteryOptimisationViewModel
|
import org.unifiedpush.android.distributor.ui.compose.BatteryOptimisationViewModel
|
||||||
|
|
@ -32,7 +36,10 @@ class MainViewModel(
|
||||||
val application: Application? = null
|
val application: Application? = null
|
||||||
) : ViewModel() {
|
) : ViewModel() {
|
||||||
constructor(application: Application) : this(
|
constructor(application: Application) : this(
|
||||||
mainUiState = MainUiState(),
|
mainUiState = MainUiState(
|
||||||
|
prismServerConfigured = !AppStore(application).prismServerUrl.isNullOrBlank() &&
|
||||||
|
!AppStore(application).prismApiKey.isNullOrBlank()
|
||||||
|
),
|
||||||
batteryOptimisationViewModel = BatteryOptimisationViewModel(application),
|
batteryOptimisationViewModel = BatteryOptimisationViewModel(application),
|
||||||
registrationsViewModel = RegistrationsViewModel(
|
registrationsViewModel = RegistrationsViewModel(
|
||||||
getRegistrationListState(application)
|
getRegistrationListState(application)
|
||||||
|
|
@ -41,7 +48,10 @@ class MainViewModel(
|
||||||
)
|
)
|
||||||
|
|
||||||
var mainUiState by mutableStateOf(mainUiState)
|
var mainUiState by mutableStateOf(mainUiState)
|
||||||
private set
|
|
||||||
|
fun updatePrismServerConfigured(configured: Boolean) {
|
||||||
|
mainUiState = mainUiState.copy(prismServerConfigured = configured)
|
||||||
|
}
|
||||||
|
|
||||||
private var lastDebugClickTime by mutableLongStateOf(0L)
|
private var lastDebugClickTime by mutableLongStateOf(0L)
|
||||||
|
|
||||||
|
|
@ -178,32 +188,34 @@ class MainViewModel(
|
||||||
|
|
||||||
Log.d(TAG, "Creating manual app: $name, token: $connectorToken")
|
Log.d(TAG, "Creating manual app: $name, token: $connectorToken")
|
||||||
|
|
||||||
|
val vapidKeys = VapidKeyGenerator.generateKeyPair()
|
||||||
|
val encryptionKeys = WebPushEncryptionKeys.generateKeySet()
|
||||||
|
|
||||||
|
val keyStore = EncryptionKeyStore(app)
|
||||||
|
keyStore.storeKeys(channelId, encryptionKeys.privateKey, encryptionKeys.authBytes, encryptionKeys.p256dh)
|
||||||
|
|
||||||
val db = DatabaseFactory.getDb(app)
|
val db = DatabaseFactory.getDb(app)
|
||||||
db.registerApp(
|
db.registerApp(
|
||||||
app.packageName,
|
app.packageName,
|
||||||
connectorToken,
|
connectorToken,
|
||||||
channelId,
|
channelId,
|
||||||
name,
|
name,
|
||||||
null,
|
vapidKeys.publicKey,
|
||||||
fullDescription
|
fullDescription
|
||||||
)
|
)
|
||||||
|
|
||||||
Log.d(TAG, "App registered in DB, sending register message to push server")
|
|
||||||
|
|
||||||
// Send register message directly to existing websocket connection
|
|
||||||
MessageSender.send(
|
MessageSender.send(
|
||||||
app,
|
app,
|
||||||
ClientMessage.Register(
|
ClientMessage.Register(
|
||||||
channelID = channelId,
|
channelID = channelId,
|
||||||
key = null
|
key = vapidKeys.publicKey
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
refreshRegistrations()
|
refreshRegistrations()
|
||||||
hideAddAppDialog()
|
hideAddAppDialog()
|
||||||
|
|
||||||
// Wait for endpoint and register with sup server
|
var endpoint: String?
|
||||||
var endpoint: String? = null
|
|
||||||
var attempts = 0
|
var attempts = 0
|
||||||
repeat(60) {
|
repeat(60) {
|
||||||
kotlinx.coroutines.delay(500)
|
kotlinx.coroutines.delay(500)
|
||||||
|
|
@ -214,11 +226,13 @@ class MainViewModel(
|
||||||
}
|
}
|
||||||
if (endpoint != null) {
|
if (endpoint != null) {
|
||||||
Log.d(TAG, "Endpoint received after $attempts attempts: $endpoint")
|
Log.d(TAG, "Endpoint received after $attempts attempts: $endpoint")
|
||||||
// Register with sup server
|
|
||||||
PrismServerClient.registerApp(
|
PrismServerClient.registerApp(
|
||||||
app,
|
app,
|
||||||
name,
|
name,
|
||||||
endpoint!!
|
endpoint!!,
|
||||||
|
vapidPrivateKey = vapidKeys.privateKey,
|
||||||
|
p256dh = encryptionKeys.p256dh,
|
||||||
|
auth = encryptionKeys.auth
|
||||||
)
|
)
|
||||||
return@launch
|
return@launch
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -36,6 +36,8 @@ class SettingsViewModel(state: SettingsState, val application: Application? = nu
|
||||||
if (trimmedUrl.isNotBlank() && state.prismApiKey.isNotBlank()) {
|
if (trimmedUrl.isNotBlank() && state.prismApiKey.isNotBlank()) {
|
||||||
publishAction(AppAction(AppAction.Action.RegisterPrismServer))
|
publishAction(AppAction(AppAction.Action.RegisterPrismServer))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
UiAction.publish(UiAction.Action.UpdatePrismServerConfigured)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -50,6 +52,8 @@ class SettingsViewModel(state: SettingsState, val application: Application? = nu
|
||||||
if (state.prismServerUrl.isNotBlank() && trimmedKey.isNotBlank()) {
|
if (state.prismServerUrl.isNotBlank() && trimmedKey.isNotBlank()) {
|
||||||
publishAction(AppAction(AppAction.Action.RegisterPrismServer))
|
publishAction(AppAction(AppAction.Action.RegisterPrismServer))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
UiAction.publish(UiAction.Action.UpdatePrismServerConfigured)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -7,7 +7,8 @@ import kotlinx.coroutines.launch
|
||||||
|
|
||||||
class UiAction(val action: Action) {
|
class UiAction(val action: Action) {
|
||||||
enum class Action {
|
enum class Action {
|
||||||
RefreshRegistrations
|
RefreshRegistrations,
|
||||||
|
UpdatePrismServerConfigured
|
||||||
}
|
}
|
||||||
|
|
||||||
fun handle(action: (Action) -> Unit) {
|
fun handle(action: (Action) -> Unit) {
|
||||||
|
|
|
||||||
|
|
@ -111,7 +111,7 @@ fun App(
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
floatingActionButton = {
|
floatingActionButton = {
|
||||||
if (currentScreen == AppScreen.Main) {
|
if (currentScreen == AppScreen.Main && mainViewModel.mainUiState.prismServerConfigured) {
|
||||||
FloatingActionButton(
|
FloatingActionButton(
|
||||||
onClick = { mainViewModel.showAddAppDialog() }
|
onClick = { mainViewModel.showAddAppDialog() }
|
||||||
) {
|
) {
|
||||||
|
|
|
||||||
|
|
@ -24,6 +24,7 @@ import androidx.lifecycle.Lifecycle
|
||||||
import androidx.lifecycle.compose.LocalLifecycleOwner
|
import androidx.lifecycle.compose.LocalLifecycleOwner
|
||||||
import androidx.lifecycle.repeatOnLifecycle
|
import androidx.lifecycle.repeatOnLifecycle
|
||||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||||
|
import app.lonecloud.prism.AppStore
|
||||||
import app.lonecloud.prism.EventBus
|
import app.lonecloud.prism.EventBus
|
||||||
import app.lonecloud.prism.R
|
import app.lonecloud.prism.R
|
||||||
import app.lonecloud.prism.activities.DistribMigrationViewModel
|
import app.lonecloud.prism.activities.DistribMigrationViewModel
|
||||||
|
|
@ -83,6 +84,15 @@ fun MainScreen(viewModel: MainViewModel, migrationViewModel: DistribMigrationVie
|
||||||
it.handle { type ->
|
it.handle { type ->
|
||||||
when (type) {
|
when (type) {
|
||||||
UiAction.Action.RefreshRegistrations -> viewModel.refreshRegistrations()
|
UiAction.Action.RefreshRegistrations -> viewModel.refreshRegistrations()
|
||||||
|
UiAction.Action.UpdatePrismServerConfigured -> {
|
||||||
|
viewModel.application?.let { app ->
|
||||||
|
val store = AppStore(app)
|
||||||
|
viewModel.updatePrismServerConfigured(
|
||||||
|
!store.prismServerUrl.isNullOrBlank() &&
|
||||||
|
!store.prismApiKey.isNullOrBlank()
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -103,12 +113,12 @@ fun MainScreen(viewModel: MainViewModel, migrationViewModel: DistribMigrationVie
|
||||||
.padding(horizontal = 16.dp),
|
.padding(horizontal = 16.dp),
|
||||||
horizontalAlignment = Alignment.Start,
|
horizontalAlignment = Alignment.Start,
|
||||||
verticalArrangement = Arrangement.spacedBy(16.dp)
|
verticalArrangement = Arrangement.spacedBy(16.dp)
|
||||||
) {
|
) innerColumn@{
|
||||||
if (migrationViewModel.state.migrated) {
|
if (migrationViewModel.state.migrated) {
|
||||||
CardDisabledForMigration {
|
CardDisabledForMigration {
|
||||||
migrationViewModel.reactivateUnifiedPush()
|
migrationViewModel.reactivateUnifiedPush()
|
||||||
}
|
}
|
||||||
return@Column
|
return@innerColumn
|
||||||
}
|
}
|
||||||
|
|
||||||
CardDisableBatteryOptimisation(viewModel.batteryOptimisationViewModel)
|
CardDisableBatteryOptimisation(viewModel.batteryOptimisationViewModel)
|
||||||
|
|
|
||||||
|
|
@ -13,5 +13,6 @@ data class MainUiState(
|
||||||
val isLoadingEndpoint: Boolean = false,
|
val isLoadingEndpoint: Boolean = false,
|
||||||
val currentEndpoint: String = "",
|
val currentEndpoint: String = "",
|
||||||
val showAddAppDialog: Boolean = false,
|
val showAddAppDialog: Boolean = false,
|
||||||
val installedApps: List<InstalledApp> = emptyList()
|
val installedApps: List<InstalledApp> = emptyList(),
|
||||||
|
val prismServerConfigured: Boolean = false
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -80,6 +80,7 @@ fun PrismServerConfigDialog(
|
||||||
var apiKey by remember { mutableStateOf("") }
|
var apiKey by remember { mutableStateOf("") }
|
||||||
var isTesting by remember { mutableStateOf(false) }
|
var isTesting by remember { mutableStateOf(false) }
|
||||||
var testResult by remember { mutableStateOf<String?>(null) }
|
var testResult by remember { mutableStateOf<String?>(null) }
|
||||||
|
var showServerChangeWarning by remember { mutableStateOf(false) }
|
||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
|
|
||||||
AlertDialog(
|
AlertDialog(
|
||||||
|
|
@ -149,8 +150,28 @@ fun PrismServerConfigDialog(
|
||||||
onDismiss()
|
onDismiss()
|
||||||
return@Button
|
return@Button
|
||||||
}
|
}
|
||||||
|
val isServerChanging = initialUrl.isNotBlank() && url.trim() != initialUrl
|
||||||
|
if (isServerChanging) {
|
||||||
|
val db = app.lonecloud.prism.DatabaseFactory.getDb(context)
|
||||||
|
val manualAppsCount = db.listApps()
|
||||||
|
.count { it.description?.startsWith("target:") == true }
|
||||||
|
if (manualAppsCount > 0) {
|
||||||
|
val oldUrl = initialUrl
|
||||||
|
val oldKey = app.lonecloud.prism.AppStore(context).prismApiKey
|
||||||
|
if (!oldUrl.isNullOrBlank() && !oldKey.isNullOrBlank()) {
|
||||||
|
app.lonecloud.prism.PrismServerClient.deleteAllApps(
|
||||||
|
context,
|
||||||
|
serverUrl = oldUrl,
|
||||||
|
apiKey = oldKey
|
||||||
|
)
|
||||||
|
}
|
||||||
|
showServerChangeWarning = true
|
||||||
|
return@Button
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
isTesting = true
|
isTesting = true
|
||||||
app.lonecloud.prism.sup.PrismServerClient.testConnection(
|
app.lonecloud.prism.PrismServerClient.testConnection(
|
||||||
url,
|
url,
|
||||||
apiKey,
|
apiKey,
|
||||||
onSuccess = {
|
onSuccess = {
|
||||||
|
|
@ -175,4 +196,51 @@ fun PrismServerConfigDialog(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
|
if (showServerChangeWarning) {
|
||||||
|
val db = app.lonecloud.prism.DatabaseFactory.getDb(context)
|
||||||
|
val manualAppsCount = db.listApps()
|
||||||
|
.count { it.description?.startsWith("target:") == true }
|
||||||
|
|
||||||
|
AlertDialog(
|
||||||
|
onDismissRequest = { showServerChangeWarning = false },
|
||||||
|
title = { Text("Change Prism Server?") },
|
||||||
|
text = {
|
||||||
|
Text(
|
||||||
|
"You have $manualAppsCount manual app${if (manualAppsCount == 1) "" else "s"}" +
|
||||||
|
" registered with the current server.\n\n" +
|
||||||
|
"Changing to $url will delete registrations from the old server" +
|
||||||
|
" and re-register with the new one."
|
||||||
|
)
|
||||||
|
},
|
||||||
|
confirmButton = {
|
||||||
|
Button(
|
||||||
|
onClick = {
|
||||||
|
showServerChangeWarning = false
|
||||||
|
isTesting = true
|
||||||
|
app.lonecloud.prism.PrismServerClient.testConnection(
|
||||||
|
url,
|
||||||
|
apiKey,
|
||||||
|
onSuccess = {
|
||||||
|
isTesting = false
|
||||||
|
testResult = "Connection successful"
|
||||||
|
onSave(url, apiKey)
|
||||||
|
},
|
||||||
|
onError = { error ->
|
||||||
|
isTesting = false
|
||||||
|
testResult = "Connection failed: $error"
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
) {
|
||||||
|
Text("Continue")
|
||||||
|
}
|
||||||
|
},
|
||||||
|
dismissButton = {
|
||||||
|
TextButton(onClick = { showServerChangeWarning = false }) {
|
||||||
|
Text("Cancel")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -10,6 +10,7 @@ import app.lonecloud.prism.AppStore
|
||||||
import app.lonecloud.prism.DatabaseFactory
|
import app.lonecloud.prism.DatabaseFactory
|
||||||
import app.lonecloud.prism.Distributor
|
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.api.data.ClientMessage
|
import app.lonecloud.prism.api.data.ClientMessage
|
||||||
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
|
||||||
|
|
@ -17,6 +18,7 @@ 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.TAG
|
import app.lonecloud.prism.utils.TAG
|
||||||
|
import app.lonecloud.prism.utils.WebPushDecryptor
|
||||||
import java.util.Calendar
|
import java.util.Calendar
|
||||||
import java.util.concurrent.TimeUnit
|
import java.util.concurrent.TimeUnit
|
||||||
import java.util.concurrent.atomic.AtomicBoolean
|
import java.util.concurrent.atomic.AtomicBoolean
|
||||||
|
|
@ -122,10 +124,50 @@ class ServerConnection(private val context: Context, private val releaseLock: ()
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun onNotification(webSocket: WebSocket, message: ServerMessage.Notification) {
|
private fun onNotification(webSocket: WebSocket, message: ServerMessage.Notification) {
|
||||||
|
val encryptedData = Base64.decode(message.data, Base64.URL_SAFE)
|
||||||
|
|
||||||
|
val db = DatabaseFactory.getDb(context)
|
||||||
|
val app = db.listApps().find { app ->
|
||||||
|
app.description?.startsWith("target:") == true
|
||||||
|
}
|
||||||
|
|
||||||
|
val decryptedData = if (app != null) {
|
||||||
|
val keyStore = EncryptionKeyStore(context)
|
||||||
|
val keys = keyStore.getKeys(message.channelID)
|
||||||
|
|
||||||
|
if (keys != null) {
|
||||||
|
val (privateKeyBytes, authSecret, publicKey) = keys
|
||||||
|
|
||||||
|
try {
|
||||||
|
val publicKeyBytes = Base64.decode(publicKey, Base64.URL_SAFE or Base64.NO_PADDING or Base64.NO_WRAP)
|
||||||
|
|
||||||
|
val decrypted = WebPushDecryptor.decrypt(
|
||||||
|
encryptedData,
|
||||||
|
privateKeyBytes,
|
||||||
|
publicKeyBytes,
|
||||||
|
authSecret
|
||||||
|
)
|
||||||
|
|
||||||
|
decrypted ?: run {
|
||||||
|
Log.e(TAG, "Decryption failed for channel ${message.channelID}")
|
||||||
|
encryptedData
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.e(TAG, "Decryption error for channel ${message.channelID}: ${e.message}")
|
||||||
|
encryptedData
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
Log.d(TAG, "No encryption keys found for manual app ${message.channelID}, message may be unencrypted")
|
||||||
|
encryptedData
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
encryptedData
|
||||||
|
}
|
||||||
|
|
||||||
sendMessage(
|
sendMessage(
|
||||||
context,
|
context,
|
||||||
message.channelID,
|
message.channelID,
|
||||||
Base64.decode(message.data, Base64.URL_SAFE)
|
decryptedData
|
||||||
)
|
)
|
||||||
ClientMessage.Ack(
|
ClientMessage.Ack(
|
||||||
arrayOf(ClientMessage.ClientAck(message.channelID, message.version))
|
arrayOf(ClientMessage.ClientAck(message.channelID, message.version))
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,54 @@
|
||||||
|
package app.lonecloud.prism.utils
|
||||||
|
|
||||||
|
import android.util.Base64
|
||||||
|
import java.security.KeyPairGenerator
|
||||||
|
import java.security.interfaces.ECPrivateKey
|
||||||
|
import java.security.interfaces.ECPublicKey
|
||||||
|
import java.security.spec.ECGenParameterSpec
|
||||||
|
|
||||||
|
/**
|
||||||
|
* VAPID key generation for Web Push (RFC 8292).
|
||||||
|
* Uses NIST P-256 (secp256r1) elliptic curve.
|
||||||
|
*/
|
||||||
|
object VapidKeyGenerator {
|
||||||
|
|
||||||
|
data class VapidKeyPair(val publicKey: String, val privateKey: String)
|
||||||
|
|
||||||
|
fun generateKeyPair(): VapidKeyPair {
|
||||||
|
val keyPairGenerator = KeyPairGenerator.getInstance("EC")
|
||||||
|
val ecSpec = ECGenParameterSpec("secp256r1")
|
||||||
|
keyPairGenerator.initialize(ecSpec)
|
||||||
|
|
||||||
|
val keyPair = keyPairGenerator.generateKeyPair()
|
||||||
|
val publicKey = keyPair.public as ECPublicKey
|
||||||
|
val privateKey = keyPair.private as ECPrivateKey
|
||||||
|
|
||||||
|
val xCoord = publicKey.w.affineX.toByteArray().trimLeadingZeros()
|
||||||
|
val yCoord = publicKey.w.affineY.toByteArray().trimLeadingZeros()
|
||||||
|
|
||||||
|
val publicKeyBytes = ByteArray(65)
|
||||||
|
publicKeyBytes[0] = 0x04
|
||||||
|
xCoord.copyInto(publicKeyBytes, 1 + (32 - xCoord.size))
|
||||||
|
yCoord.copyInto(publicKeyBytes, 33 + (32 - yCoord.size))
|
||||||
|
|
||||||
|
val privateKeyBytes = privateKey.s.toByteArray().trimLeadingZeros()
|
||||||
|
val privateKeyPadded = ByteArray(32)
|
||||||
|
privateKeyBytes.copyInto(privateKeyPadded, 32 - privateKeyBytes.size)
|
||||||
|
|
||||||
|
return VapidKeyPair(
|
||||||
|
publicKey = base64UrlEncode(publicKeyBytes),
|
||||||
|
privateKey = base64UrlEncode(privateKeyPadded)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun base64UrlEncode(data: ByteArray): String =
|
||||||
|
Base64.encodeToString(data, Base64.URL_SAFE or Base64.NO_PADDING or Base64.NO_WRAP)
|
||||||
|
|
||||||
|
private fun ByteArray.trimLeadingZeros(): ByteArray {
|
||||||
|
var i = 0
|
||||||
|
while (i < size && this[i] == 0.toByte()) {
|
||||||
|
i++
|
||||||
|
}
|
||||||
|
return copyOfRange(i, size)
|
||||||
|
}
|
||||||
|
}
|
||||||
116
app/src/main/java/app/lonecloud/prism/utils/WebPushDecryptor.kt
Normal file
116
app/src/main/java/app/lonecloud/prism/utils/WebPushDecryptor.kt
Normal file
|
|
@ -0,0 +1,116 @@
|
||||||
|
package app.lonecloud.prism.utils
|
||||||
|
|
||||||
|
import com.google.crypto.tink.subtle.EllipticCurves
|
||||||
|
import com.google.crypto.tink.subtle.Hkdf
|
||||||
|
import java.nio.ByteBuffer
|
||||||
|
import java.security.KeyFactory
|
||||||
|
import java.security.interfaces.ECPrivateKey
|
||||||
|
import java.security.spec.PKCS8EncodedKeySpec
|
||||||
|
import javax.crypto.Cipher
|
||||||
|
import javax.crypto.spec.GCMParameterSpec
|
||||||
|
import javax.crypto.spec.SecretKeySpec
|
||||||
|
|
||||||
|
/**
|
||||||
|
* WebPush message decryption (RFC 8291 - aes128gcm).
|
||||||
|
* Uses Tink for all cryptographic primitives (ECDH, HKDF, AES-GCM).
|
||||||
|
*/
|
||||||
|
object WebPushDecryptor {
|
||||||
|
|
||||||
|
private const val SALT_SIZE = 16
|
||||||
|
private const val RECORD_SIZE_LEN = 4
|
||||||
|
private const val PUBLIC_KEY_SIZE_LEN = 1
|
||||||
|
private const val PUBLIC_KEY_SIZE = 65
|
||||||
|
private const val CONTENT_CODING_HEADER_SIZE = SALT_SIZE + RECORD_SIZE_LEN + PUBLIC_KEY_SIZE_LEN + PUBLIC_KEY_SIZE
|
||||||
|
private const val TAG_SIZE = 16
|
||||||
|
private const val CIPHERTEXT_OVERHEAD = CONTENT_CODING_HEADER_SIZE + 1 + TAG_SIZE
|
||||||
|
private const val AUTH_SECRET_SIZE = 16
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Decrypt a WebPush message.
|
||||||
|
*
|
||||||
|
* @param ciphertext The encrypted message
|
||||||
|
* @param privateKeyBytes The recipient's private key (PKCS8 encoded)
|
||||||
|
* @param publicKeyBytes The recipient's public key (uncompressed P-256 point, 65 bytes)
|
||||||
|
* @param authSecret The auth secret (16 bytes)
|
||||||
|
* @return Decrypted plaintext or null on failure
|
||||||
|
*/
|
||||||
|
fun decrypt(
|
||||||
|
ciphertext: ByteArray,
|
||||||
|
privateKeyBytes: ByteArray,
|
||||||
|
publicKeyBytes: ByteArray,
|
||||||
|
authSecret: ByteArray
|
||||||
|
): ByteArray? {
|
||||||
|
try {
|
||||||
|
if (ciphertext.size < CIPHERTEXT_OVERHEAD) return null
|
||||||
|
if (publicKeyBytes.size != PUBLIC_KEY_SIZE) return null
|
||||||
|
if (authSecret.size != AUTH_SECRET_SIZE) return null
|
||||||
|
|
||||||
|
val record = ByteBuffer.wrap(ciphertext)
|
||||||
|
val salt = ByteArray(SALT_SIZE)
|
||||||
|
record.get(salt)
|
||||||
|
|
||||||
|
val recordSize = record.int
|
||||||
|
val publicKeySize = record.get().toInt()
|
||||||
|
if (publicKeySize != PUBLIC_KEY_SIZE) return null
|
||||||
|
|
||||||
|
val ephemeralPublicKeyBytes = ByteArray(PUBLIC_KEY_SIZE)
|
||||||
|
record.get(ephemeralPublicKeyBytes)
|
||||||
|
|
||||||
|
val payload = ByteArray(ciphertext.size - CONTENT_CODING_HEADER_SIZE)
|
||||||
|
record.get(payload)
|
||||||
|
|
||||||
|
val keyFactory = KeyFactory.getInstance("EC")
|
||||||
|
val privateKeySpec = PKCS8EncodedKeySpec(privateKeyBytes)
|
||||||
|
val privateKey = keyFactory.generatePrivate(privateKeySpec) as ECPrivateKey
|
||||||
|
|
||||||
|
val ephemeralPublicPoint = EllipticCurves.pointDecode(
|
||||||
|
EllipticCurves.CurveType.NIST_P256,
|
||||||
|
EllipticCurves.PointFormatType.UNCOMPRESSED,
|
||||||
|
ephemeralPublicKeyBytes
|
||||||
|
)
|
||||||
|
|
||||||
|
val ecdhSecret = EllipticCurves.computeSharedSecret(privateKey, ephemeralPublicPoint)
|
||||||
|
|
||||||
|
val ikm = computeIkm(ecdhSecret, authSecret, publicKeyBytes, ephemeralPublicKeyBytes)
|
||||||
|
val cek = computeCek(ikm, salt)
|
||||||
|
val nonce = computeNonce(ikm, salt)
|
||||||
|
|
||||||
|
val cipher = Cipher.getInstance("AES/GCM/NoPadding")
|
||||||
|
val params = GCMParameterSpec(8 * TAG_SIZE, nonce)
|
||||||
|
cipher.init(Cipher.DECRYPT_MODE, SecretKeySpec(cek, "AES"), params)
|
||||||
|
val plaintext = cipher.doFinal(payload)
|
||||||
|
|
||||||
|
var index = plaintext.size - 1
|
||||||
|
while (index > 0 && plaintext[index] == 0.toByte()) {
|
||||||
|
index--
|
||||||
|
}
|
||||||
|
|
||||||
|
if (plaintext[index] != 0x02.toByte()) return null
|
||||||
|
|
||||||
|
return plaintext.copyOf(index)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun computeIkm(
|
||||||
|
ecdhSecret: ByteArray,
|
||||||
|
authSecret: ByteArray,
|
||||||
|
uaPublic: ByteArray,
|
||||||
|
asPublic: ByteArray
|
||||||
|
): ByteArray {
|
||||||
|
val ikmInfo = "WebPush: info\u0000".toByteArray(Charsets.UTF_8)
|
||||||
|
val keyInfo = ikmInfo + uaPublic + asPublic
|
||||||
|
return Hkdf.computeHkdf("HMACSHA256", ecdhSecret, authSecret, keyInfo, 32)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun computeCek(ikm: ByteArray, salt: ByteArray): ByteArray {
|
||||||
|
val cekInfo = "Content-Encoding: aes128gcm\u0000".toByteArray(Charsets.UTF_8)
|
||||||
|
return Hkdf.computeHkdf("HMACSHA256", ikm, salt, cekInfo, 16)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun computeNonce(ikm: ByteArray, salt: ByteArray): ByteArray {
|
||||||
|
val nonceInfo = "Content-Encoding: nonce\u0000".toByteArray(Charsets.UTF_8)
|
||||||
|
return Hkdf.computeHkdf("HMACSHA256", ikm, salt, nonceInfo, 12)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,43 @@
|
||||||
|
package app.lonecloud.prism.utils
|
||||||
|
|
||||||
|
import android.util.Base64
|
||||||
|
import com.google.crypto.tink.subtle.EllipticCurves
|
||||||
|
import java.security.KeyPairGenerator
|
||||||
|
import java.security.SecureRandom
|
||||||
|
import java.security.interfaces.ECPublicKey
|
||||||
|
import java.security.spec.ECGenParameterSpec
|
||||||
|
|
||||||
|
object WebPushEncryptionKeys {
|
||||||
|
|
||||||
|
data class EncryptionKeySet(
|
||||||
|
val p256dh: String,
|
||||||
|
val auth: String,
|
||||||
|
val privateKey: ByteArray,
|
||||||
|
val authBytes: ByteArray
|
||||||
|
)
|
||||||
|
|
||||||
|
fun generateKeySet(): EncryptionKeySet {
|
||||||
|
val keyPairGenerator = KeyPairGenerator.getInstance("EC")
|
||||||
|
keyPairGenerator.initialize(ECGenParameterSpec("secp256r1"))
|
||||||
|
val keyPair = keyPairGenerator.generateKeyPair()
|
||||||
|
|
||||||
|
val publicKeyBytes = EllipticCurves.pointEncode(
|
||||||
|
EllipticCurves.CurveType.NIST_P256,
|
||||||
|
EllipticCurves.PointFormatType.UNCOMPRESSED,
|
||||||
|
(keyPair.public as ECPublicKey).w
|
||||||
|
)
|
||||||
|
|
||||||
|
val authBytes = ByteArray(16)
|
||||||
|
SecureRandom().nextBytes(authBytes)
|
||||||
|
|
||||||
|
return EncryptionKeySet(
|
||||||
|
p256dh = base64UrlEncode(publicKeyBytes),
|
||||||
|
auth = base64UrlEncode(authBytes),
|
||||||
|
privateKey = keyPair.private.encoded,
|
||||||
|
authBytes = authBytes
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun base64UrlEncode(data: ByteArray): String =
|
||||||
|
Base64.encodeToString(data, Base64.URL_SAFE or Base64.NO_PADDING or Base64.NO_WRAP)
|
||||||
|
}
|
||||||
|
|
@ -6,6 +6,7 @@ androidx-work = "2.11.0"
|
||||||
appcompat = "1.7.1"
|
appcompat = "1.7.1"
|
||||||
unifiedpush_distributor = "0.5.6"
|
unifiedpush_distributor = "0.5.6"
|
||||||
unifiedpush_distributor_ui = "0.5.5"
|
unifiedpush_distributor_ui = "0.5.5"
|
||||||
|
tink = "1.15.0"
|
||||||
kotlin = "2.2.20"
|
kotlin = "2.2.20"
|
||||||
kotlinx_serializationJson = "1.9.0"
|
kotlinx_serializationJson = "1.9.0"
|
||||||
ktlint = "14.0.1"
|
ktlint = "14.0.1"
|
||||||
|
|
@ -29,6 +30,7 @@ androidx-work-runtime-ktx = { module = "androidx.work:work-runtime-ktx", version
|
||||||
appcompat = { module = "androidx.appcompat:appcompat", version.ref = "appcompat" }
|
appcompat = { module = "androidx.appcompat:appcompat", version.ref = "appcompat" }
|
||||||
unifiedpush-distributor = { module = "org.unifiedpush.android:distributor", version.ref = "unifiedpush_distributor" }
|
unifiedpush-distributor = { module = "org.unifiedpush.android:distributor", version.ref = "unifiedpush_distributor" }
|
||||||
unifiedpush-distributor-ui = { module = "org.unifiedpush.android:distributor-ui", version.ref = "unifiedpush_distributor_ui" }
|
unifiedpush-distributor-ui = { module = "org.unifiedpush.android:distributor-ui", version.ref = "unifiedpush_distributor_ui" }
|
||||||
|
tink-android = { module = "com.google.crypto.tink:tink-android", version.ref = "tink" }
|
||||||
kotlin-gradle-plugin = { module = "org.jetbrains.kotlin:kotlin-gradle-plugin", version.ref = "kotlin" }
|
kotlin-gradle-plugin = { module = "org.jetbrains.kotlin:kotlin-gradle-plugin", version.ref = "kotlin" }
|
||||||
kotlinx-serialization-json = { module = "org.jetbrains.kotlinx:kotlinx-serialization-json", version.ref = "kotlinx_serializationJson" }
|
kotlinx-serialization-json = { module = "org.jetbrains.kotlinx:kotlinx-serialization-json", version.ref = "kotlinx_serializationJson" }
|
||||||
ktlint-gradle = { module = "org.jlleitschuh.gradle:ktlint-gradle", version.ref = "ktlint"}
|
ktlint-gradle = { module = "org.jlleitschuh.gradle:ktlint-gradle", version.ref = "ktlint"}
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue