Notifications and sending songs to friends
Reactions to your songs, new followers, and songs sent to you are now notifications: a bell on the feed (app and website) with an unread count that updates live, and a list that marks them read. "Send ✉" on a song sends it to people who follow you, with an optional note. Settings picks which kinds to be notified about. Push delivery uses no third-party accounts: - Android: UnifiedPush through a distributor app (ntfy), pointed at our own ntfy server (docker-compose.ntfy.yml). Works the same in the F-Droid build; no Firebase. - Browsers: standard Web Push from the existing service worker. Both use Web Push encryption with a VAPID key pair the server makes and keeps in the database. Push addresses are checked like any other user-supplied URL. Setup is in docs/PUSH.md; the privacy page covers the new data. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,25 @@
|
||||
plugins {
|
||||
id 'com.android.library'
|
||||
id 'expo-module-gradle-plugin'
|
||||
}
|
||||
|
||||
group = 'tech.chrisedwards.musicshare.push'
|
||||
version = '0.1.0'
|
||||
|
||||
android {
|
||||
namespace "tech.chrisedwards.musicshare.push"
|
||||
defaultConfig {
|
||||
versionCode 1
|
||||
versionName "0.1.0"
|
||||
}
|
||||
lintOptions {
|
||||
abortOnError false
|
||||
}
|
||||
}
|
||||
|
||||
dependencies {
|
||||
// UnifiedPush: notifications through a distributor app the user picks (e.g. ntfy on our own
|
||||
// server) instead of Google's Firebase. Open source (Apache-2.0), no Play Services.
|
||||
implementation "org.unifiedpush.android:connector:3.3.5"
|
||||
implementation "androidx.core:core-ktx:1.16.0"
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
||||
|
||||
<application>
|
||||
<!-- Receives endpoints and messages from the UnifiedPush distributor (via the connector library). -->
|
||||
<service
|
||||
android:name=".MusicsharePushService"
|
||||
android:exported="false">
|
||||
<intent-filter>
|
||||
<action android:name="org.unifiedpush.android.connector.PUSH_EVENT" />
|
||||
</intent-filter>
|
||||
</service>
|
||||
</application>
|
||||
</manifest>
|
||||
+88
@@ -0,0 +1,88 @@
|
||||
package tech.chrisedwards.musicshare.push
|
||||
|
||||
import android.app.NotificationChannel
|
||||
import android.app.NotificationManager
|
||||
import android.app.PendingIntent
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.net.Uri
|
||||
import android.os.Build
|
||||
import android.util.Log
|
||||
import androidx.core.app.NotificationCompat
|
||||
import androidx.core.app.NotificationManagerCompat
|
||||
import org.json.JSONObject
|
||||
import org.unifiedpush.android.connector.FailedReason
|
||||
import org.unifiedpush.android.connector.PushService
|
||||
import org.unifiedpush.android.connector.data.PushEndpoint
|
||||
import org.unifiedpush.android.connector.data.PushMessage
|
||||
|
||||
/**
|
||||
* Runs without the app UI: the distributor (e.g. ntfy) wakes it with new endpoints and
|
||||
* messages. Messages arrive already decrypted by the connector library.
|
||||
*/
|
||||
class MusicsharePushService : PushService() {
|
||||
override fun onNewEndpoint(endpoint: PushEndpoint, instance: String) {
|
||||
val keys = endpoint.pubKeySet ?: run {
|
||||
Log.w(TAG, "endpoint without keys; can't receive encrypted pushes")
|
||||
return
|
||||
}
|
||||
val ep = Endpoint(endpoint.url, keys.pubKey, keys.auth)
|
||||
PushPrefs(this).endpoint = ep
|
||||
PushBus.emit(ep)
|
||||
}
|
||||
|
||||
override fun onMessage(message: PushMessage, instance: String) {
|
||||
try {
|
||||
show(this, JSONObject(String(message.content, Charsets.UTF_8)))
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "couldn't show push", e)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onRegistrationFailed(reason: FailedReason, instance: String) {
|
||||
Log.w(TAG, "registration failed: $reason")
|
||||
forget()
|
||||
}
|
||||
|
||||
override fun onUnregistered(instance: String) = forget()
|
||||
|
||||
private fun forget() {
|
||||
PushPrefs(this).endpoint = null
|
||||
PushBus.emit(null)
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val TAG = "MusicsharePush"
|
||||
private const val CHANNEL = "activity"
|
||||
|
||||
/** { id, title, body, path } from the server; tapping opens musicshare://<path>. */
|
||||
fun show(context: Context, data: JSONObject) {
|
||||
val manager = NotificationManagerCompat.from(context)
|
||||
if (!manager.areNotificationsEnabled()) return
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||
val channel = NotificationChannel(CHANNEL, "Activity", NotificationManager.IMPORTANCE_DEFAULT)
|
||||
channel.description = "Reactions, new followers and songs sent to you"
|
||||
context.getSystemService(NotificationManager::class.java).createNotificationChannel(channel)
|
||||
}
|
||||
val id = data.optString("id", System.currentTimeMillis().toString())
|
||||
val open = Intent(Intent.ACTION_VIEW, Uri.parse("musicshare:/" + data.optString("path", "/")))
|
||||
.setPackage(context.packageName)
|
||||
.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
|
||||
val tap = PendingIntent.getActivity(context, id.hashCode(), open, PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT)
|
||||
val notification = NotificationCompat.Builder(context, CHANNEL)
|
||||
.setSmallIcon(R.drawable.ic_stat_musicshare)
|
||||
.setColor(0xFF7C4DFF.toInt())
|
||||
.setContentTitle(data.optString("title"))
|
||||
.setContentText(data.optString("body"))
|
||||
.setStyle(NotificationCompat.BigTextStyle().bigText(data.optString("body")))
|
||||
.setContentIntent(tap)
|
||||
.setAutoCancel(true)
|
||||
.build()
|
||||
try {
|
||||
manager.notify(id.hashCode(), notification)
|
||||
} catch (e: SecurityException) {
|
||||
Log.w(TAG, "notification permission missing", e)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+49
@@ -0,0 +1,49 @@
|
||||
package tech.chrisedwards.musicshare.push
|
||||
|
||||
import expo.modules.kotlin.Promise
|
||||
import expo.modules.kotlin.exception.Exceptions
|
||||
import expo.modules.kotlin.modules.Module
|
||||
import expo.modules.kotlin.modules.ModuleDefinition
|
||||
import org.unifiedpush.android.connector.UnifiedPush
|
||||
|
||||
class PushModule : Module() {
|
||||
private val context get() = appContext.reactContext ?: throw Exceptions.ReactContextLost()
|
||||
|
||||
private val listener: (Endpoint?) -> Unit = { sendEvent("onEndpoint", mapOf("endpoint" to it?.toMap())) }
|
||||
|
||||
override fun definition() = ModuleDefinition {
|
||||
Name("MusicsharePush")
|
||||
|
||||
Events("onEndpoint")
|
||||
|
||||
OnStartObserving { PushBus.add(listener) }
|
||||
OnStopObserving { PushBus.remove(listener) }
|
||||
|
||||
/** Package names of installed UnifiedPush distributors (ntfy etc.). */
|
||||
Function("distributors") { UnifiedPush.getDistributors(context) }
|
||||
|
||||
/** The distributor in use, if registered. */
|
||||
Function("currentDistributor") { UnifiedPush.getAckDistributor(context) }
|
||||
|
||||
/** The endpoint to give the server: { url, p256dh, auth }, or null. */
|
||||
Function("getEndpoint") { PushPrefs(context).endpoint?.toMap() }
|
||||
|
||||
/**
|
||||
* Picks a distributor (asks if there are several) and registers. Resolves false when
|
||||
* there's none installed. The endpoint arrives later through onEndpoint.
|
||||
*/
|
||||
AsyncFunction("register") { vapidPublicKey: String, promise: Promise ->
|
||||
// It may ask the user to pick a distributor, so it needs the app on screen.
|
||||
val activity = appContext.currentActivity ?: throw Exceptions.MissingActivity()
|
||||
UnifiedPush.tryUseCurrentOrDefaultDistributor(activity) { ok ->
|
||||
if (ok) UnifiedPush.register(context, "default", "musicshare notifications", vapidPublicKey)
|
||||
promise.resolve(ok)
|
||||
}
|
||||
}
|
||||
|
||||
Function("unregister") {
|
||||
UnifiedPush.unregister(context, "default")
|
||||
PushPrefs(context).endpoint = null
|
||||
}
|
||||
}
|
||||
}
|
||||
+31
@@ -0,0 +1,31 @@
|
||||
package tech.chrisedwards.musicshare.push
|
||||
|
||||
import android.content.Context
|
||||
|
||||
/** The current push endpoint, kept so JS can send it to the server whenever the app opens. */
|
||||
internal class PushPrefs(context: Context) {
|
||||
private val prefs = context.applicationContext.getSharedPreferences("musicshare.push", Context.MODE_PRIVATE)
|
||||
|
||||
var endpoint: Endpoint?
|
||||
get() {
|
||||
val url = prefs.getString("url", null) ?: return null
|
||||
return Endpoint(url, prefs.getString("p256dh", null) ?: return null, prefs.getString("auth", null) ?: return null)
|
||||
}
|
||||
set(value) {
|
||||
val edit = prefs.edit()
|
||||
if (value == null) edit.clear() else edit.putString("url", value.url).putString("p256dh", value.p256dh).putString("auth", value.auth)
|
||||
edit.apply()
|
||||
}
|
||||
}
|
||||
|
||||
internal data class Endpoint(val url: String, val p256dh: String, val auth: String) {
|
||||
fun toMap() = mapOf("url" to url, "p256dh" to p256dh, "auth" to auth)
|
||||
}
|
||||
|
||||
/** Tells the JS module (when the app is open) that the endpoint changed. */
|
||||
internal object PushBus {
|
||||
private val listeners = mutableSetOf<(Endpoint?) -> Unit>()
|
||||
@Synchronized fun add(listener: (Endpoint?) -> Unit) { listeners.add(listener) }
|
||||
@Synchronized fun remove(listener: (Endpoint?) -> Unit) { listeners.remove(listener) }
|
||||
@Synchronized fun emit(endpoint: Endpoint?) { listeners.forEach { it(endpoint) } }
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
<!-- Status bar icon: a music note (status bar icons must be single-colour). -->
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:viewportWidth="24"
|
||||
android:viewportHeight="24">
|
||||
<path
|
||||
android:fillColor="#FFFFFFFF"
|
||||
android:pathData="M12,3v10.55A4,4 0,1 0,14 17V7h4V3h-6z" />
|
||||
</vector>
|
||||
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"platforms": ["android"],
|
||||
"android": {
|
||||
"modules": ["tech.chrisedwards.musicshare.push.PushModule"]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
// Android-only native module; the default export is null where it is unavailable.
|
||||
export { default } from './src/PushModule';
|
||||
export type { PushEndpoint } from './src/PushModule';
|
||||
@@ -0,0 +1,20 @@
|
||||
import { NativeModule, requireOptionalNativeModule } from 'expo';
|
||||
|
||||
export interface PushEndpoint {
|
||||
url: string;
|
||||
p256dh: string;
|
||||
auth: string;
|
||||
}
|
||||
|
||||
type Events = { onEndpoint: (event: { endpoint: PushEndpoint | null }) => void };
|
||||
|
||||
declare class MusicsharePushModule extends NativeModule<Events> {
|
||||
distributors(): string[];
|
||||
currentDistributor(): string | null;
|
||||
getEndpoint(): PushEndpoint | null;
|
||||
register(vapidPublicKey: string): Promise<boolean>;
|
||||
unregister(): void;
|
||||
}
|
||||
|
||||
/** Android only (UnifiedPush); null elsewhere and in Expo Go. */
|
||||
export default requireOptionalNativeModule<MusicsharePushModule>('MusicsharePush');
|
||||
Reference in New Issue
Block a user