Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions android/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,7 @@ dependencies {

implementation(platform("com.google.firebase:firebase-bom:34.16.0"))
implementation("com.google.firebase:firebase-messaging-ktx:24.1.2")
implementation("org.unifiedpush.android:connector:3.3.3")
testImplementation("junit:junit:4.13.2")
testImplementation("io.mockk:mockk-android:1.14.11")
testImplementation("io.mockk:mockk-agent:1.14.11")
Expand Down
18 changes: 18 additions & 0 deletions android/src/main/AndroidManifest.xml
Original file line number Diff line number Diff line change
@@ -1,5 +1,11 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android">

<queries>
<intent>
<action android:name="org.unifiedpush.android.distributor.REGISTER" />
</intent>
</queries>

<application>
<receiver android:name="app.tauri.notification.TimedNotificationPublisher" android:exported="true" />
<receiver android:name="app.tauri.notification.NotificationDismissReceiver" android:exported="false" />
Expand All @@ -22,6 +28,18 @@
<action android:name="com.google.firebase.MESSAGING_EVENT" />
</intent-filter>
</service>

<!-- UnifiedPush connector receiver -->
<receiver
android:name="app.tauri.notification.UnifiedPushReceiver"
android:exported="true">
<intent-filter>
<action android:name="org.unifiedpush.android.connector.NEW_ENDPOINT" />
<action android:name="org.unifiedpush.android.connector.UNREGISTERED" />
<action android:name="org.unifiedpush.android.connector.MESSAGE" />
<action android:name="org.unifiedpush.android.connector.REGISTRATION_FAILED" />
</intent-filter>
</receiver>
</application>
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" />
<uses-permission android:name="android.permission.WAKE_LOCK" />
Expand Down
86 changes: 80 additions & 6 deletions android/src/main/java/app/tauri/notification/NotificationPlugin.kt
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import app.tauri.plugin.JSArray
import app.tauri.plugin.JSObject
import app.tauri.plugin.Plugin
import com.google.firebase.messaging.FirebaseMessaging
import org.unifiedpush.android.connector.UnifiedPush

const val LOCAL_NOTIFICATIONS = "permissionState"

Expand Down Expand Up @@ -63,6 +64,11 @@ class SetClickListenerActiveArgs {
var active: Boolean = false
}

@InvokeArg
class DistributorArgs {
var distributor: String? = null
}

@InvokeArg
class ActiveNotification {
var id: Int = 0
Expand Down Expand Up @@ -401,18 +407,24 @@ class NotificationPlugin(private val activity: Activity): Plugin(activity) {
}
}

// If we already have a cached token, return it immediately
proceedPushRegistration(invoke)
}

private fun proceedPushRegistration(invoke: Invoke) {
if (UnifiedPush.getSavedDistributor(activity) != null) {
pendingTokenInvoke = invoke
UnifiedPush.register(activity, "default")
return
}

cachedToken?.let {
val result = JSObject()
result.put("deviceToken", it)
invoke.resolve(result)
return
}

// Store the invoke to respond later when we get the token
pendingTokenInvoke = invoke

// Request the FCM token
getFirebaseToken()
}

Expand All @@ -423,6 +435,13 @@ class NotificationPlugin(private val activity: Activity): Plugin(activity) {
return
}

if (UnifiedPush.getSavedDistributor(activity) != null) {
UnifiedPush.unregister(activity, "default")
cachedToken = null
invoke.resolve()
return
}

FirebaseMessaging.getInstance().deleteToken().addOnCompleteListener { task ->
if (!task.isSuccessful) {
invoke.reject("Failed to delete FCM token: ${task.exception?.message}")
Expand All @@ -441,8 +460,63 @@ class NotificationPlugin(private val activity: Activity): Plugin(activity) {
return
}

// Permissions granted, now get the token
getFirebaseToken()
proceedPushRegistration(invoke)
}

@Command
fun listDistributors(invoke: Invoke) {
val distributors = UnifiedPush.getDistributors(activity)
val result = JSObject()
val arr = JSArray()
distributors.forEach { arr.put(it) }
result.put("distributors", arr)
invoke.resolve(result)
}

@Command
fun setDistributor(invoke: Invoke) {
val args = invoke.parseArgs(DistributorArgs::class.java)
val distributor = args.distributor
if (distributor == null) {
invoke.reject("Distributor parameter is required")
return
}
UnifiedPush.saveDistributor(activity, distributor)
invoke.resolve()
}

@Command
fun setToken(invoke: Invoke) {
invoke.resolve()
}

fun onUnifiedPushNewEndpoint(endpoint: String) {
cachedToken = endpoint
val result = JSObject()
result.put("deviceToken", endpoint)
pendingTokenInvoke?.resolve(result)
pendingTokenInvoke = null

val data = JSObject()
data.put("token", endpoint)
trigger("push-token", data)
}

fun onUnifiedPushRegistrationFailed(reason: String?) {
pendingTokenInvoke?.reject(reason ?: "UnifiedPush registration failed")
pendingTokenInvoke = null
}

fun onUnifiedPushUnregistered() {
pendingTokenInvoke?.resolve(JSObject())
pendingTokenInvoke = null
cachedToken = null
}

fun onUnifiedPushMessage(content: String) {
val data = JSObject()
data.put("message", content)
trigger("push-message", data)
}

private fun getFirebaseToken() {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
package app.tauri.notification

import android.app.NotificationChannel
import android.app.NotificationManager
import android.app.PendingIntent
import android.content.Context
import android.os.Build
import androidx.core.app.NotificationCompat
import androidx.core.app.NotificationManagerCompat
import org.json.JSONObject

object UnifiedPushNotifier {
private const val CHANNEL_ID = "messages"

fun showFromPush(context: Context, rawMessage: String) {
val notification = try {
JSONObject(rawMessage).optJSONObject("notification")
} catch (e: Exception) {
null
} ?: return

val roomId = notification.optString("room_id")
val sender = notification.optString("sender_display_name")
val title = notification.optString("room_name").ifEmpty { "New message" }
val body = buildBody(notification, sender)

ensureChannel(context)

val iconId = context.resources
.getIdentifier("notification_icon", "drawable", context.packageName)
.takeIf { it != 0 } ?: android.R.drawable.ic_dialog_info

val builder = NotificationCompat.Builder(context, CHANNEL_ID)
.setSmallIcon(iconId)
.setContentTitle(title)
.setContentText(body)
.setAutoCancel(true)
.setPriority(NotificationCompat.PRIORITY_HIGH)

context.packageManager.getLaunchIntentForPackage(context.packageName)?.let { intent ->
builder.setContentIntent(
PendingIntent.getActivity(
context,
roomId.hashCode(),
intent,
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
)
)
}

val id = (roomId.ifEmpty { notification.optString("event_id") }).hashCode()
NotificationManagerCompat.from(context).notify(id, builder.build())
}

private fun buildBody(notification: JSONObject, sender: String): String {
val prefix = if (sender.isNotEmpty()) "$sender: " else ""
if (notification.optString("type") == "m.room.encrypted") {
return "${prefix}Encrypted message"
}
val text = notification.optJSONObject("content")?.optString("body")?.takeIf { it.isNotEmpty() }
return if (text != null) "$prefix$text" else "${prefix}New message"
}

private fun ensureChannel(context: Context) {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return
val manager = context.getSystemService(NotificationManager::class.java) ?: return
if (manager.getNotificationChannel(CHANNEL_ID) == null) {
manager.createNotificationChannel(
NotificationChannel(CHANNEL_ID, "Messages", NotificationManager.IMPORTANCE_HIGH).apply {
description = "Matrix message and invite notifications"
}
)
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
package app.tauri.notification

import android.content.Context
import org.unifiedpush.android.connector.FailedReason
import org.unifiedpush.android.connector.MessagingReceiver
import org.unifiedpush.android.connector.data.PushEndpoint
import org.unifiedpush.android.connector.data.PushMessage

class UnifiedPushReceiver : MessagingReceiver() {
override fun onNewEndpoint(context: Context, endpoint: PushEndpoint, instance: String) {
NotificationPlugin.instance?.onUnifiedPushNewEndpoint(endpoint.url)
}

override fun onRegistrationFailed(context: Context, reason: FailedReason, instance: String) {
NotificationPlugin.instance?.onUnifiedPushRegistrationFailed(reason.name)
}

override fun onUnregistered(context: Context, instance: String) {
NotificationPlugin.instance?.onUnifiedPushUnregistered()
}

override fun onMessage(context: Context, message: PushMessage, instance: String) {
val content = String(message.content, Charsets.UTF_8)
val plugin = NotificationPlugin.instance
if (plugin != null) {
plugin.onUnifiedPushMessage(content)
} else {
UnifiedPushNotifier.showFromPush(context, content)
}
}
}
15 changes: 12 additions & 3 deletions src/commands.rs
Original file line number Diff line number Diff line change
Expand Up @@ -59,7 +59,10 @@ pub async fn unregister_for_push_notifications<R: Runtime>(
}
}

#[cfg(all(desktop, target_os = "linux", feature = "push-notifications"))]
#[cfg(all(
feature = "push-notifications",
any(all(desktop, target_os = "linux"), target_os = "android")
))]
#[command]
pub async fn list_distributors<R: Runtime>(
_app: AppHandle<R>,
Expand All @@ -68,7 +71,10 @@ pub async fn list_distributors<R: Runtime>(
notification.list_distributors().await
}

#[cfg(all(desktop, target_os = "linux", feature = "push-notifications"))]
#[cfg(all(
feature = "push-notifications",
any(all(desktop, target_os = "linux"), target_os = "android")
))]
#[command]
pub async fn set_distributor<R: Runtime>(
_app: AppHandle<R>,
Expand All @@ -78,7 +84,10 @@ pub async fn set_distributor<R: Runtime>(
notification.set_distributor(name).await
}

#[cfg(all(desktop, target_os = "linux", feature = "push-notifications"))]
#[cfg(all(
feature = "push-notifications",
any(all(desktop, target_os = "linux"), target_os = "android")
))]
#[command]
pub async fn set_token<R: Runtime>(
_app: AppHandle<R>,
Expand Down
15 changes: 12 additions & 3 deletions src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -316,11 +316,20 @@ pub fn init<R: Runtime>() -> TauriPlugin<R, Option<PluginConfig>> {
listeners::register_listener,
#[cfg(desktop)]
listeners::remove_listener,
#[cfg(all(desktop, target_os = "linux", feature = "push-notifications"))]
#[cfg(all(
feature = "push-notifications",
any(all(desktop, target_os = "linux"), target_os = "android")
))]
commands::list_distributors,
#[cfg(all(desktop, target_os = "linux", feature = "push-notifications"))]
#[cfg(all(
feature = "push-notifications",
any(all(desktop, target_os = "linux"), target_os = "android")
))]
commands::set_distributor,
#[cfg(all(desktop, target_os = "linux", feature = "push-notifications"))]
#[cfg(all(
feature = "push-notifications",
any(all(desktop, target_os = "linux"), target_os = "android")
))]
commands::set_token,
])
.setup(|app, api| {
Expand Down
29 changes: 29 additions & 0 deletions src/mobile.rs
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,35 @@ impl<R: Runtime> Notifications<R> {
}
}

#[cfg(all(target_os = "android", feature = "push-notifications"))]
pub async fn list_distributors(&self) -> crate::Result<Vec<String>> {
self.0
.run_mobile_plugin_async::<crate::models::DistributorsResponse>("listDistributors", ())
.await
.map(|r| r.distributors)
.map_err(Into::into)
}

#[cfg(all(target_os = "android", feature = "push-notifications"))]
pub async fn set_distributor(&self, name: String) -> crate::Result<()> {
let mut args = HashMap::new();
args.insert("distributor", name);
self.0
.run_mobile_plugin_async::<()>("setDistributor", args)
.await
.map_err(Into::into)
}

#[cfg(all(target_os = "android", feature = "push-notifications"))]
pub async fn set_token(&self, token: String) -> crate::Result<()> {
let mut args = HashMap::new();
args.insert("token", token);
self.0
.run_mobile_plugin_async::<()>("setToken", args)
.await
.map_err(Into::into)
}

pub async fn permission_state(&self) -> crate::Result<PermissionState> {
self.0
.run_mobile_plugin_async::<PermissionResponse>("checkPermissions", ())
Expand Down
6 changes: 6 additions & 0 deletions src/models.rs
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,12 @@ pub struct PushNotificationResponse {
pub device_token: String,
}

#[cfg(all(target_os = "android", feature = "push-notifications"))]
#[derive(Debug, Deserialize)]
pub struct DistributorsResponse {
pub distributors: Vec<String>,
}

#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct Attachment {
Expand Down