Skip to content

notif: Use Zulip's custom notification sound on Android #982

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 4 commits into from
Nov 14, 2024
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
120 changes: 118 additions & 2 deletions android/app/src/main/kotlin/com/zulip/flutter/Notifications.g.kt
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,7 @@ data class NotificationChannel (
val importance: Long,
val name: String? = null,
val lightsEnabled: Boolean? = null,
val soundUrl: String? = null,
val vibrationPattern: LongArray? = null

) {
Expand All @@ -72,8 +73,9 @@ data class NotificationChannel (
val importance = __pigeon_list[1].let { num -> if (num is Int) num.toLong() else num as Long }
val name = __pigeon_list[2] as String?
val lightsEnabled = __pigeon_list[3] as Boolean?
val vibrationPattern = __pigeon_list[4] as LongArray?
return NotificationChannel(id, importance, name, lightsEnabled, vibrationPattern)
val soundUrl = __pigeon_list[4] as String?
val vibrationPattern = __pigeon_list[5] as LongArray?
return NotificationChannel(id, importance, name, lightsEnabled, soundUrl, vibrationPattern)
}
}
fun toList(): List<Any?> {
Expand All @@ -82,6 +84,7 @@ data class NotificationChannel (
importance,
name,
lightsEnabled,
soundUrl,
vibrationPattern,
)
}
Expand Down Expand Up @@ -345,6 +348,47 @@ data class StatusBarNotification (
)
}
}

/**
* Represents details about a notification sound stored in the
* shared media store.
*
* Returned as a list entry by
* [AndroidNotificationHostApi.listStoredSoundsInNotificationsDirectory].
*
* Generated class from Pigeon that represents data sent in messages.
*/
data class StoredNotificationSound (
/** The display name of the sound file. */
val fileName: String,
/**
* Specifies whether this file was created by the app.
*
* It is true if the `MediaStore.Audio.Media.OWNER_PACKAGE_NAME` key in the
* metadata matches the app's package name.
*/
val isOwned: Boolean,
/** A `content://…` URL pointing to the sound file. */
val contentUrl: String

) {
companion object {
@Suppress("LocalVariableName")
fun fromList(__pigeon_list: List<Any?>): StoredNotificationSound {
val fileName = __pigeon_list[0] as String
val isOwned = __pigeon_list[1] as Boolean
val contentUrl = __pigeon_list[2] as String
return StoredNotificationSound(fileName, isOwned, contentUrl)
}
}
fun toList(): List<Any?> {
return listOf(
fileName,
isOwned,
contentUrl,
)
}
}
private object NotificationsPigeonCodec : StandardMessageCodec() {
override fun readValueOfType(type: Byte, buffer: ByteBuffer): Any? {
return when (type) {
Expand Down Expand Up @@ -393,6 +437,11 @@ private object NotificationsPigeonCodec : StandardMessageCodec() {
StatusBarNotification.fromList(it)
}
}
138.toByte() -> {
return (readValue(buffer) as? List<Any?>)?.let {
StoredNotificationSound.fromList(it)
}
}
else -> super.readValueOfType(type, buffer)
}
}
Expand Down Expand Up @@ -434,6 +483,10 @@ private object NotificationsPigeonCodec : StandardMessageCodec() {
stream.write(137)
writeValue(stream, value.toList())
}
is StoredNotificationSound -> {
stream.write(138)
writeValue(stream, value.toList())
}
else -> super.writeValue(stream, value)
}
}
Expand All @@ -459,6 +512,36 @@ interface AndroidNotificationHostApi {
* See: https://developer.android.com/reference/kotlin/androidx/core/app/NotificationManagerCompat#deleteNotificationChannel(java.lang.String)
*/
fun deleteNotificationChannel(channelId: String)
/**
* The list of notification sound files present under `Notifications/Zulip/`
* in the device's shared media storage,
* found with `android.content.ContentResolver.query`.
*
* This is a complex ad-hoc method.
* For detailed behavior, see its implementation.
*
* Requires minimum of Android 10 (API 29) or higher.
*
* See: https://developer.android.com/reference/android/content/ContentResolver#query(android.net.Uri,%20java.lang.String[],%20java.lang.String,%20java.lang.String[],%20java.lang.String)
*/
fun listStoredSoundsInNotificationsDirectory(): List<StoredNotificationSound>
/**
* Wraps `android.content.ContentResolver.insert` combined with
* `android.content.ContentResolver.openOutputStream` and
* `android.content.res.Resources.openRawResource`.
*
* Copies a raw resource audio file to `Notifications/Zulip/`
* directory in device's shared media storage. Returns the URL
* of the target file in media store.
*
* Requires minimum of Android 10 (API 29) or higher.
*
* See:
* https://developer.android.com/reference/android/content/ContentResolver#insert(android.net.Uri,%20android.content.ContentValues)
* https://developer.android.com/reference/android/content/ContentResolver#openOutputStream(android.net.Uri)
* https://developer.android.com/reference/android/content/res/Resources#openRawResource(int)
*/
fun copySoundResourceToMediaStore(targetFileDisplayName: String, sourceResourceName: String): String
/**
* Corresponds to `android.app.NotificationManager.notify`,
* combined with `androidx.core.app.NotificationCompat.Builder`.
Expand Down Expand Up @@ -571,6 +654,39 @@ interface AndroidNotificationHostApi {
channel.setMessageHandler(null)
}
}
run {
val channel = BasicMessageChannel<Any?>(binaryMessenger, "dev.flutter.pigeon.zulip.AndroidNotificationHostApi.listStoredSoundsInNotificationsDirectory$separatedMessageChannelSuffix", codec)
if (api != null) {
channel.setMessageHandler { _, reply ->
val wrapped: List<Any?> = try {
listOf(api.listStoredSoundsInNotificationsDirectory())
} catch (exception: Throwable) {
wrapError(exception)
}
reply.reply(wrapped)
}
} else {
channel.setMessageHandler(null)
}
}
run {
val channel = BasicMessageChannel<Any?>(binaryMessenger, "dev.flutter.pigeon.zulip.AndroidNotificationHostApi.copySoundResourceToMediaStore$separatedMessageChannelSuffix", codec)
if (api != null) {
channel.setMessageHandler { message, reply ->
val args = message as List<Any?>
val targetFileDisplayNameArg = args[0] as String
val sourceResourceNameArg = args[1] as String
val wrapped: List<Any?> = try {
listOf(api.copySoundResourceToMediaStore(targetFileDisplayNameArg, sourceResourceNameArg))
} catch (exception: Throwable) {
wrapError(exception)
}
reply.reply(wrapped)
}
} else {
channel.setMessageHandler(null)
}
}
run {
val channel = BasicMessageChannel<Any?>(binaryMessenger, "dev.flutter.pigeon.zulip.AndroidNotificationHostApi.notify$separatedMessageChannelSuffix", codec)
if (api != null) {
Expand Down
99 changes: 99 additions & 0 deletions android/app/src/main/kotlin/com/zulip/flutter/ZulipPlugin.kt
Original file line number Diff line number Diff line change
@@ -1,10 +1,17 @@
package com.zulip.flutter

import android.annotation.SuppressLint
import android.content.ContentUris
import android.content.ContentValues
import android.content.Context
import android.content.Intent
import android.media.AudioAttributes
import android.net.Uri
import android.os.Build
import android.os.Bundle
import android.os.Environment
import android.provider.MediaStore
import android.provider.MediaStore.Audio.Media as AudioStore
import android.util.Log
import androidx.annotation.Keep
import androidx.core.app.NotificationChannelCompat
Expand Down Expand Up @@ -44,11 +51,22 @@ fun toPigeonPerson(person: androidx.core.app.Person): Person {

private class AndroidNotificationHost(val context: Context)
: AndroidNotificationHostApi {
// The directory we store our notification sounds into,
// expressed as a relative path suitable for:
// https://developer.android.com/reference/kotlin/android/provider/MediaStore.MediaColumns#RELATIVE_PATH:kotlin.String
val notificationSoundsDirectoryPath = "${Environment.DIRECTORY_NOTIFICATIONS}/Zulip/"

class ResolverFailedException(msg: String) : RuntimeException(msg)

override fun createNotificationChannel(channel: NotificationChannel) {
val notificationChannel = NotificationChannelCompat
.Builder(channel.id, channel.importance.toInt()).apply {
channel.name?.let { setName(it) }
channel.lightsEnabled?.let { setLightsEnabled(it) }
channel.soundUrl?.let {
setSound(Uri.parse(it),
AudioAttributes.Builder().setUsage(AudioAttributes.USAGE_NOTIFICATION).build())
}
channel.vibrationPattern?.let { setVibrationPattern(it) }
}.build()
NotificationManagerCompat.from(context).createNotificationChannel(notificationChannel)
Expand All @@ -69,6 +87,87 @@ private class AndroidNotificationHost(val context: Context)
NotificationManagerCompat.from(context).deleteNotificationChannel(channelId)
}

override fun listStoredSoundsInNotificationsDirectory(): List<StoredNotificationSound> {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.Q) {
throw UnsupportedOperationException()
}

// Query and cursor-loop based on:
// https://developer.android.com/training/data-storage/shared/media#query-collection
val collection = AudioStore.getContentUri(MediaStore.VOLUME_EXTERNAL_PRIMARY)
val projection = arrayOf(AudioStore._ID, AudioStore.DISPLAY_NAME, AudioStore.OWNER_PACKAGE_NAME)
val selection = "${AudioStore.RELATIVE_PATH}=?"
val selectionArgs = arrayOf(notificationSoundsDirectoryPath)
val sortOrder = "${AudioStore._ID} ASC"

val sounds = mutableListOf<StoredNotificationSound>()
val cursor = context.contentResolver.query(
collection,
projection,
selection,
selectionArgs,
sortOrder,
) ?: throw ResolverFailedException("resolver.query failed")
cursor.use {
val idColumn = cursor.getColumnIndexOrThrow(AudioStore._ID)
val nameColumn = cursor.getColumnIndexOrThrow(AudioStore.DISPLAY_NAME)
val ownerColumn = cursor.getColumnIndexOrThrow(AudioStore.OWNER_PACKAGE_NAME)
while (cursor.moveToNext()) {
val id = cursor.getLong(idColumn)
val fileName = cursor.getString(nameColumn)
val ownerPackageName = cursor.getString(ownerColumn)

val contentUrl = ContentUris.withAppendedId(collection, id)
sounds.add(StoredNotificationSound(
fileName = fileName,
isOwned = context.packageName == ownerPackageName,
contentUrl = contentUrl.toString()
))
}
}
return sounds
}

@SuppressLint(
// For `getIdentifier`. TODO make a cleaner API.
"DiscouragedApi")
override fun copySoundResourceToMediaStore(
targetFileDisplayName: String,
sourceResourceName: String
): String {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.Q) {
throw UnsupportedOperationException()
}

class ResolverFailedException(msg: String) : RuntimeException(msg)

val resolver = context.contentResolver
val collection = AudioStore.getContentUri(MediaStore.VOLUME_EXTERNAL_PRIMARY)

// Based on: https://developer.android.com/training/data-storage/shared/media#add-item
val url = resolver.insert(collection, ContentValues().apply {
put(AudioStore.DISPLAY_NAME, targetFileDisplayName)
put(AudioStore.RELATIVE_PATH, notificationSoundsDirectoryPath)
put(AudioStore.IS_NOTIFICATION, 1)
put(AudioStore.IS_PENDING, 1)
}) ?: throw ResolverFailedException("resolver.insert failed")

(resolver.openOutputStream(url, "wt")
?: throw ResolverFailedException("resolver.open… failed"))
.use { outputStream ->
val resourceId = context.resources.getIdentifier(
sourceResourceName, "raw", context.packageName)
context.resources.openRawResource(resourceId)
.use { it.copyTo(outputStream) }
}

resolver.update(
url, ContentValues().apply { put(AudioStore.IS_PENDING, 0) },
null, null)

return url.toString()
}

@SuppressLint(
// If permission is missing, `notify` will throw an exception.
// Which hopefully will propagate to Dart, and then it's up to Dart code to handle it.
Expand Down
Binary file added android/app/src/main/res/raw/chime2.m4a
Binary file not shown.
Binary file added android/app/src/main/res/raw/chime3.m4a
Binary file not shown.
Binary file added android/app/src/main/res/raw/chime4.m4a
Binary file not shown.
2 changes: 1 addition & 1 deletion android/app/src/main/res/raw/keep.xml
Original file line number Diff line number Diff line change
Expand Up @@ -12,5 +12,5 @@
https://github.com/zulip/zulip-flutter/issues/528
-->
<resources xmlns:tools="http://schemas.android.com/tools"
tools:keep="@drawable/zulip_notification"
tools:keep="@drawable/zulip_notification,@raw/chime2,@raw/chime3,@raw/chime4"
/>
Loading