diff --git a/.github/workflows/android-ci.yml b/.github/workflows/android-ci.yml new file mode 100644 index 0000000..e5d184a --- /dev/null +++ b/.github/workflows/android-ci.yml @@ -0,0 +1,41 @@ +name: Android CI + +on: + push: + branches: [main, master] + paths: + - 'android/**' + - '.github/workflows/android-ci.yml' + pull_request: + branches: [main, master] + paths: + - 'android/**' + - '.github/workflows/android-ci.yml' + +jobs: + check: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Setup JDK + uses: actions/setup-java@v4 + with: + distribution: 'temurin' + java-version: '17' + + - name: Verify lockfile is up-to-date + run: | + cd android + chmod +x gradlew + ./gradlew dependencies --write-locks + if ! git diff --exit-code app/gradle.lockfile; then + echo "āŒ Lockfile is out of date. Run: cd android && ./gradlew dependencies --write-locks" + exit 1 + fi + echo "āœ… Lockfile is up-to-date" + + - name: Build APK + run: | + cd android + ./gradlew assembleRelease diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e58bbb5..73e2c6f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -3,8 +3,14 @@ name: CI on: push: branches: [main, master] + paths-ignore: + - 'android/**' + - '.github/workflows/android-ci.yml' pull_request: branches: [main, master] + paths-ignore: + - 'android/**' + - '.github/workflows/android-ci.yml' jobs: check: @@ -20,5 +26,12 @@ jobs: - name: Install dependencies run: bun install - - name: Run checks - run: bun check + - name: Lint and type check + run: bun run check + + - name: Build server + run: cd server && bun run build + + - name: Build proton-bridge + run: cd proton-bridge && bun run build + diff --git a/NOTICE b/NOTICE index f8ad45a..c53801a 100644 --- a/NOTICE +++ b/NOTICE @@ -11,10 +11,13 @@ The Android application (android/) contains modified code from: Licensed under Apache License 2.0 Major modifications: - -- Replaced HTTP polling with Signal-based message delivery for privacy -- Removed web-based features and simplified UI -- Intended for self-hosted, low-volume personal use +- Completely replaced HTTP/WebSocket polling with Signal-based push delivery +- Removed all user authentication, attachment downloads, and action buttons +- Removed Firebase, certificate pinning, and instant delivery features +- Simplified database schema (removed 4 tables, 17 fields) +- Removed ~1000+ lines of code for unused features +- Changed theme and branding +- Designed for privacy-focused, self-hosted personal use only The original ntfy-android license is included below: diff --git a/README.md b/README.md index 659201d..5746c99 100644 --- a/README.md +++ b/README.md @@ -8,8 +8,15 @@ SUP is a UnifiedPush distributor that routes push notifications through Signal, ## Architecture -- **Server** (Bun/TypeScript): Receives UnifiedPush webhooks and forwards them via signal-cli to Signal groups -- **Android App** (Kotlin): Monitors Signal notifications, extracts payloads, and wakes target apps +SUP consists of three services that **MUST run together on the same machine**: + +- **sup-server** (Bun/TypeScript): Receives webhooks, sends Signal messages via signal-cli +- **protonmail-bridge** (Official Proton): Decrypts ProtonMail emails, runs local IMAP server +- **proton-bridge** (Custom): Monitors IMAP, forwards to sup-server + +All services communicate over a private Docker network with no external exposure except Signal protocol. **Separating these services across multiple machines would expose plaintext IMAP traffic and compromise security.** + +**Android App** (Kotlin): Monitors Signal notifications, extracts UnifiedPush payloads, delivers to apps ## Why? @@ -17,44 +24,122 @@ Traditional push notification systems (ntfy, FCM) require persistent WebSocket c ## Setup +**āš ļø DOCKER COMPOSE REQUIRED**: The services must be deployed together using `docker compose`. Running individual Dockerfiles separately is not supported and will compromise security. + ### Prerequisites -- **Docker** (recommended) - includes Java 25 JRE -- **Or for local development:** - - Bun 1.3+ - - Java 21+ JRE (signal-cli is Java bytecode, not native) - -### Quick Start with Docker +#### Installing Docker on Arch Linux ```bash -docker run -d -p 8080:8080 -v sup-data:/root/.local/share/signal-cli ghcr.io/lone-cloud/sup:latest +# Install Docker and Compose plugin +sudo pacman -S docker docker-buildx + +# Start Docker service +sudo systemctl start docker +sudo systemctl enable docker + +# Add your user to docker group (logout/login required) +sudo usermod -aG docker $USER ``` -Visit `http://localhost:8080/link` to link your Signal account via QR code. +After adding yourself to the docker group, **logout and login** for it to take effect. -> **Note:** The `-v sup-data:...` persists your Signal account data. Without it, you'll need to re-link on every restart. +### Quick Start with Docker Compose -**Optional: Add API key for production:** - -```bash -docker run -d -p 8080:8080 -e API_KEY=your-secret -v sup-data:/root/.local/share/signal-cli ghcr.io/lone-cloud/sup:latest -``` - -### Alternative: Docker Compose +**Without ProtonMail** (just UnifiedPush): ```bash # Clone the repo git clone https://github.com/lone-cloud/sup.git cd sup -# Optional: Create .env file for API key -echo "API_KEY=your-secret" > .env +# Create .env file +cat > .env << 'EOF' +# Required: API key for securing your server +API_KEY=your-random-secret-key-here -# Run -docker-compose up -d +# Optional: Enable verbose logging +VERBOSE=false +EOF + +# Build and start SUP server only +docker compose up -d + +# Link your Signal account (one-time setup) +# Visit http://localhost:8080/link and scan QR code with Signal app ``` -Visit `http://localhost:8080/link` to link your Signal account. +**With ProtonMail** (UnifiedPush + email notifications): + +```bash +# Same setup as above, then start with protonmail profile +docker compose --profile protonmail up -d +``` + +### ProtonMail Integration (Optional) + +To receive ProtonMail notifications via Signal: + +1. **Initialize ProtonMail Bridge** (one-time setup): + + ```bash + docker compose run --rm protonmail-bridge init + ``` + +2. **Login to ProtonMail**: + - At the `>>>` prompt, run: `login` + - Enter your ProtonMail email + - Enter your ProtonMail password + - Enter your 2FA code + +3. **Get IMAP credentials**: + - Run: `info` + - Copy the Username and Password shown + - Run: `exit` to quit + +4. **Add credentials to .env**: + + ```bash + # Add these to your .env file + BRIDGE_IMAP_USERNAME=your-email@proton.me + BRIDGE_IMAP_PASSWORD=bridge-generated-password-from-info-command + ``` + +5. **Start all services with ProtonMail**: + + ```bash + docker compose --profile protonmail up -d + ``` + +Your phone will now receive Signal notifications when ProtonMail receives new emails. + +### Checking Logs + +```bash +# Without ProtonMail +docker compose logs -f + +# With ProtonMail +docker compose --profile protonmail logs -f + +# View specific service +docker compose logs -f sup-server +docker compose --profile protonmail logs -f proton-bridge +docker compose --profile protonmail logs -f protonmail-bridge +``` + +### Stopping Services + +```bash +# Without ProtonMail +docker compose down + +# With ProtonMail +docker compose --profile protonmail down + +# Stop and remove volumes (warning: deletes Signal/ProtonMail data) +docker compose --profile protonmail down -v +``` ### Development @@ -97,7 +182,7 @@ Download the latest APK from [GitHub Releases](https://github.com/lone-cloud/sup **Certificate Fingerprint for Obtainium verification:** -``` +```text 0D:3C:99:15:0E:12:1A:DE:0D:AE:05:CB:16:46:5E:65:31:56:DC:D6:98:87:59:4E:79:B1:0D:AE:1E:56:F2:E8 ``` diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts index a4b1172..b8398ca 100644 --- a/android/app/build.gradle.kts +++ b/android/app/build.gradle.kts @@ -78,56 +78,27 @@ dependencies { implementation("androidx.constraintlayout:constraintlayout:2.2.1") implementation("androidx.activity:activity-ktx:1.12.2") implementation("androidx.fragment:fragment-ktx:1.8.9") - implementation("androidx.work:work-runtime-ktx:2.11.0") - implementation("androidx.preference:preference-ktx:1.2.1") // JSON (Gson) - implementation("com.google.code.gson:gson:2.13.2") + implementation("com.google.code.gson:gson:2.13.1") // Room (SQLite) - val roomVersion = "2.6.1" + val roomVersion = "2.8.4" implementation("androidx.room:room-runtime:$roomVersion") ksp("androidx.room:room-compiler:$roomVersion") implementation("androidx.room:room-ktx:$roomVersion") // OkHttp - implementation("com.squareup.okhttp3:okhttp:5.3.2") + implementation("com.squareup.okhttp3:okhttp:4.12.0") // RecyclerView implementation("androidx.recyclerview:recyclerview:1.4.0") - // Swipe to refresh - implementation("androidx.swiperefreshlayout:swiperefreshlayout:1.2.0") - // Material Design implementation("com.google.android.material:material:1.13.0") // LiveData implementation("androidx.lifecycle:lifecycle-livedata-ktx:2.10.0") - implementation("androidx.legacy:legacy-support-v4:1.0.0") - - // Image viewer - implementation("com.github.stfalcon-studio:StfalconImageViewer:1.0.1") - - // Glide (GIF support) - val glideVersion = "5.0.5" - implementation("com.github.bumptech.glide:glide:$glideVersion") - ksp("com.github.bumptech.glide:ksp:$glideVersion") - - // Better click handling for links - implementation("me.saket:better-link-movement-method:2.2.0") - - // Markdown - implementation("io.noties.markwon:core:4.6.2") - implementation("io.noties.markwon:image-picasso:4.6.2") - implementation("io.noties.markwon:image:4.6.2") - implementation("io.noties.markwon:linkify:4.6.2") - implementation("io.noties.markwon:ext-tables:4.6.2") - implementation("io.noties.markwon:ext-strikethrough:4.6.2") - - // Markdown dependencies (R8 requirements) - implementation("pl.droidsonroids.gif:android-gif-drawable:1.2.29") - implementation("com.caverock:androidsvg:1.4") // UnifiedPush implementation("com.github.UnifiedPush:android-connector:3.0.10") diff --git a/android/app/gradle.lockfile b/android/app/gradle.lockfile new file mode 100644 index 0000000..78ba976 --- /dev/null +++ b/android/app/gradle.lockfile @@ -0,0 +1,106 @@ +# This is a Gradle generated file for dependency locking. +# Manual edits can break the build and are not advised. +# This file is expected to be part of source control. +androidx.activity:activity-ktx:1.12.2=releaseRuntimeClasspath +androidx.activity:activity:1.12.2=releaseRuntimeClasspath +androidx.annotation:annotation-experimental:1.5.0=releaseRuntimeClasspath +androidx.annotation:annotation-jvm:1.9.1=releaseRuntimeClasspath +androidx.annotation:annotation:1.9.1=releaseRuntimeClasspath +androidx.appcompat:appcompat-resources:1.7.1=releaseRuntimeClasspath +androidx.appcompat:appcompat:1.7.1=releaseRuntimeClasspath +androidx.arch.core:core-common:2.2.0=releaseRuntimeClasspath +androidx.arch.core:core-runtime:2.2.0=releaseRuntimeClasspath +androidx.cardview:cardview:1.0.0=releaseRuntimeClasspath +androidx.collection:collection-jvm:1.5.0=releaseRuntimeClasspath +androidx.collection:collection-ktx:1.5.0=releaseRuntimeClasspath +androidx.collection:collection:1.5.0=releaseRuntimeClasspath +androidx.compose.runtime:runtime-annotation-android:1.9.0=releaseRuntimeClasspath +androidx.compose.runtime:runtime-annotation:1.9.0=releaseRuntimeClasspath +androidx.concurrent:concurrent-futures:1.1.0=releaseRuntimeClasspath +androidx.constraintlayout:constraintlayout-core:1.1.1=releaseRuntimeClasspath +androidx.constraintlayout:constraintlayout:2.2.1=releaseRuntimeClasspath +androidx.coordinatorlayout:coordinatorlayout:1.1.0=releaseRuntimeClasspath +androidx.core:core-ktx:1.17.0=releaseRuntimeClasspath +androidx.core:core-viewtree:1.0.0=releaseRuntimeClasspath +androidx.core:core:1.17.0=releaseRuntimeClasspath +androidx.cursoradapter:cursoradapter:1.0.0=releaseRuntimeClasspath +androidx.customview:customview-poolingcontainer:1.0.0=releaseRuntimeClasspath +androidx.customview:customview:1.1.0=releaseRuntimeClasspath +androidx.databinding:viewbinding:8.9.1=releaseRuntimeClasspath +androidx.drawerlayout:drawerlayout:1.1.1=releaseRuntimeClasspath +androidx.dynamicanimation:dynamicanimation:1.1.0=releaseRuntimeClasspath +androidx.emoji2:emoji2-views-helper:1.3.0=releaseRuntimeClasspath +androidx.emoji2:emoji2:1.3.0=releaseRuntimeClasspath +androidx.fragment:fragment-ktx:1.8.9=releaseRuntimeClasspath +androidx.fragment:fragment:1.8.9=releaseRuntimeClasspath +androidx.graphics:graphics-shapes-android:1.0.1=releaseRuntimeClasspath +androidx.graphics:graphics-shapes:1.0.1=releaseRuntimeClasspath +androidx.interpolator:interpolator:1.0.0=releaseRuntimeClasspath +androidx.lifecycle:lifecycle-common-jvm:2.10.0=releaseRuntimeClasspath +androidx.lifecycle:lifecycle-common:2.10.0=releaseRuntimeClasspath +androidx.lifecycle:lifecycle-livedata-core-ktx:2.10.0=releaseRuntimeClasspath +androidx.lifecycle:lifecycle-livedata-core:2.10.0=releaseRuntimeClasspath +androidx.lifecycle:lifecycle-livedata-ktx:2.10.0=releaseRuntimeClasspath +androidx.lifecycle:lifecycle-livedata:2.10.0=releaseRuntimeClasspath +androidx.lifecycle:lifecycle-process:2.10.0=releaseRuntimeClasspath +androidx.lifecycle:lifecycle-runtime-android:2.10.0=releaseRuntimeClasspath +androidx.lifecycle:lifecycle-runtime-ktx-android:2.10.0=releaseRuntimeClasspath +androidx.lifecycle:lifecycle-runtime-ktx:2.10.0=releaseRuntimeClasspath +androidx.lifecycle:lifecycle-runtime:2.10.0=releaseRuntimeClasspath +androidx.lifecycle:lifecycle-viewmodel-android:2.10.0=releaseRuntimeClasspath +androidx.lifecycle:lifecycle-viewmodel-ktx:2.10.0=releaseRuntimeClasspath +androidx.lifecycle:lifecycle-viewmodel-savedstate-android:2.10.0=releaseRuntimeClasspath +androidx.lifecycle:lifecycle-viewmodel-savedstate:2.10.0=releaseRuntimeClasspath +androidx.lifecycle:lifecycle-viewmodel:2.10.0=releaseRuntimeClasspath +androidx.loader:loader:1.0.0=releaseRuntimeClasspath +androidx.navigationevent:navigationevent-android:1.0.1=releaseRuntimeClasspath +androidx.navigationevent:navigationevent:1.0.1=releaseRuntimeClasspath +androidx.profileinstaller:profileinstaller:1.4.0=releaseRuntimeClasspath +androidx.recyclerview:recyclerview:1.4.0=releaseRuntimeClasspath +androidx.resourceinspection:resourceinspection-annotation:1.0.1=releaseRuntimeClasspath +androidx.room:room-common-jvm:2.8.4=releaseRuntimeClasspath +androidx.room:room-common:2.8.4=releaseRuntimeClasspath +androidx.room:room-ktx:2.8.4=releaseRuntimeClasspath +androidx.room:room-runtime-android:2.8.4=releaseRuntimeClasspath +androidx.room:room-runtime:2.8.4=releaseRuntimeClasspath +androidx.savedstate:savedstate-android:1.4.0=releaseRuntimeClasspath +androidx.savedstate:savedstate-ktx:1.4.0=releaseRuntimeClasspath +androidx.savedstate:savedstate:1.4.0=releaseRuntimeClasspath +androidx.sqlite:sqlite-android:2.6.2=releaseRuntimeClasspath +androidx.sqlite:sqlite-framework-android:2.6.2=releaseRuntimeClasspath +androidx.sqlite:sqlite-framework:2.6.2=releaseRuntimeClasspath +androidx.sqlite:sqlite:2.6.2=releaseRuntimeClasspath +androidx.startup:startup-runtime:1.1.1=releaseRuntimeClasspath +androidx.tracing:tracing:1.2.0=releaseRuntimeClasspath +androidx.transition:transition:1.5.0=releaseRuntimeClasspath +androidx.vectordrawable:vectordrawable-animated:1.1.0=releaseRuntimeClasspath +androidx.vectordrawable:vectordrawable:1.1.0=releaseRuntimeClasspath +androidx.versionedparcelable:versionedparcelable:1.1.1=releaseRuntimeClasspath +androidx.viewpager2:viewpager2:1.1.0-beta02=releaseRuntimeClasspath +androidx.viewpager:viewpager:1.0.0=releaseRuntimeClasspath +com.github.UnifiedPush:android-connector:3.0.10=releaseRuntimeClasspath +com.google.android.material:material:1.13.0=releaseRuntimeClasspath +com.google.code.findbugs:jsr305:3.0.2=releaseRuntimeClasspath +com.google.code.gson:gson:2.13.1=releaseRuntimeClasspath +com.google.crypto.tink:tink:1.17.0=releaseRuntimeClasspath +com.google.errorprone:error_prone_annotations:2.38.0=releaseRuntimeClasspath +com.google.guava:listenablefuture:1.0=releaseRuntimeClasspath +com.google.protobuf:protobuf-java:4.28.2=releaseRuntimeClasspath +com.squareup.okhttp3:okhttp:4.12.0=releaseRuntimeClasspath +com.squareup.okio:okio-jvm:3.6.0=releaseRuntimeClasspath +com.squareup.okio:okio:3.6.0=releaseRuntimeClasspath +org.jetbrains.kotlin:kotlin-bom:1.8.22=releaseRuntimeClasspath +org.jetbrains.kotlin:kotlin-stdlib-common:2.1.20=releaseRuntimeClasspath +org.jetbrains.kotlin:kotlin-stdlib-jdk7:1.9.10=releaseRuntimeClasspath +org.jetbrains.kotlin:kotlin-stdlib-jdk8:1.9.10=releaseRuntimeClasspath +org.jetbrains.kotlin:kotlin-stdlib:2.1.20=releaseRuntimeClasspath +org.jetbrains.kotlinx:kotlinx-coroutines-android:1.9.0=releaseRuntimeClasspath +org.jetbrains.kotlinx:kotlinx-coroutines-bom:1.9.0=releaseRuntimeClasspath +org.jetbrains.kotlinx:kotlinx-coroutines-core-jvm:1.9.0=releaseRuntimeClasspath +org.jetbrains.kotlinx:kotlinx-coroutines-core:1.9.0=releaseRuntimeClasspath +org.jetbrains.kotlinx:kotlinx-serialization-bom:1.7.3=releaseRuntimeClasspath +org.jetbrains.kotlinx:kotlinx-serialization-core-jvm:1.7.3=releaseRuntimeClasspath +org.jetbrains.kotlinx:kotlinx-serialization-core:1.7.3=releaseRuntimeClasspath +org.jetbrains:annotations:23.0.0=releaseRuntimeClasspath +org.jspecify:jspecify:1.0.0=releaseRuntimeClasspath +empty= diff --git a/android/app/src/main/java/com/lonecloud/sup/SignalNotificationListener.kt b/android/app/src/main/java/com/lonecloud/sup/SignalNotificationListener.kt index 19f2f1a..1aa5797 100644 --- a/android/app/src/main/java/com/lonecloud/sup/SignalNotificationListener.kt +++ b/android/app/src/main/java/com/lonecloud/sup/SignalNotificationListener.kt @@ -51,159 +51,41 @@ class SignalNotificationListener : NotificationListenerService() { Log.d(TAG, "Signal notification: title=$title, text=$text") - when { - title.startsWith("SUP - ") && !title.contains("(UP)") -> { - // Direct notification channel - val topic = title.removePrefix("SUP - ") - parseAndDisplayNotification(topic, text) - } - title.startsWith("SUP - ") && title.contains("(UP)") -> { - // UnifiedPush notification - val appName = title.removePrefix("SUP - ").substringBefore(" (UP)") - parseAndDeliverUnifiedPush(appName, text) - } + if (text.startsWith("[UP:")) { + parseAndDeliverUnifiedPush(text) } } - private fun parseAndDisplayNotification(topic: String, message: String) { - serviceScope.launch { - try { - val subscription = db.subscriptionDao().get( - prefs.getString("server_url", "") ?: "", - topic - ) ?: return@launch - - if (subscription.mutedUntil > System.currentTimeMillis() / 1000) { - Log.d(TAG, "Subscription $topic is muted") - return@launch - } - - val lines = message.lines() - val (title, body, priority, clickUrl) = parseNotificationMessage(lines) - - val notif = Notification( - id = "${System.currentTimeMillis()}-${Random.nextInt()}", - subscriptionId = subscription.id, - timestamp = System.currentTimeMillis() / 1000, - title = title ?: topic, - message = body, - notificationId = Random.nextInt(Int.MAX_VALUE), - priority = priority, - tags = "", - deleted = false - ) - - db.notificationDao().add(notif) - displayNotification(subscription.displayName ?: topic, notif) - - Log.d(TAG, "Displayed notification for topic: $topic") - } catch (e: Exception) { - Log.e(TAG, "Failed to display notification", e) - } - } - } - - private fun parseAndDeliverUnifiedPush(appName: String, message: String) { + private fun parseAndDeliverUnifiedPush(message: String) { try { - val endpoint = prefs.getString("endpoint_$appName", null) - val token = prefs.getString("token_$appName", null) - - if (endpoint == null || token == null) { - Log.w(TAG, "No mapping found for app: $appName") + val endpointMatch = Regex("""\[UP:([^\]]+)\]""").find(message) + val endpointId = endpointMatch?.groupValues?.get(1) ?: run { + Log.w(TAG, "No endpoint ID found in message") return } - val lines = message.lines() - val body = lines.drop(1).joinToString("\n").trim() + val subscription = runBlocking { + db.subscriptionDao().getByUpAppId(endpointId) + } ?: run { + Log.w(TAG, "No subscription found for upAppId: $endpointId") + return + } + + val payload = message.substringAfter("]").trim() val intent = Intent("org.unifiedpush.android.connector.MESSAGE").apply { - putExtra("token", token) - putExtra("message", body) - `package` = getAppPackageFromToken(token) + putExtra("token", subscription.upConnectorToken) // UnifiedPush connector token + putExtra("message", payload) + `package` = subscription.upAppId // Target app package } sendBroadcast(intent) - Log.d(TAG, "Delivered UnifiedPush notification to $appName") + Log.d(TAG, "Delivered UnifiedPush notification to app: ${subscription.upAppId}") } catch (e: Exception) { Log.e(TAG, "Failed to parse/deliver UnifiedPush notification", e) } } - private fun parseNotificationMessage(lines: List): NotificationData { - var title: String? = null - var body = "" - var priority = 3 // default - var clickUrl: String? = null - - for (line in lines) { - when { - line.startsWith("🚨") || line.startsWith("āš ļø") || line.startsWith("šŸ””") || - line.startsWith("šŸ”‰") || line.startsWith("šŸ”•") -> { - // Parse priority from emoji - priority = when { - line.startsWith("🚨") -> 5 // urgent - line.startsWith("āš ļø") -> 4 // high - line.startsWith("šŸ””") -> 3 // default - line.startsWith("šŸ”‰") -> 2 // low - line.startsWith("šŸ”•") -> 1 // min - else -> 3 - } - // Extract title (remove emoji and **markdown**) - title = line.substring(2).trim() - .removePrefix("**").removeSuffix("**").trim() - } - line.startsWith("šŸ”—") -> { - clickUrl = line.removePrefix("šŸ”—").trim() - } - line.startsWith("_Tags:") -> { - // Ignore tags line for now - } - line.isNotBlank() && title != null -> { - // Body content - if (body.isNotEmpty()) body += "\n" - body += line - } - } - } - - return NotificationData(title, body.ifBlank { lines.joinToString("\n") }, priority, clickUrl) - } - - private fun displayNotification(topicName: String, notification: Notification) { - val notificationManager = getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager - - val intent = Intent(this, MainActivity::class.java) - - val pendingIntent = PendingIntent.getActivity( - this, - notification.notificationId, - intent, - PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT - ) - - val builder = NotificationCompat.Builder(this, CHANNEL_ID) - .setSmallIcon(R.drawable.ic_notification) - .setContentTitle(notification.title) - .setContentText(notification.message) - .setStyle(NotificationCompat.BigTextStyle().bigText(notification.message)) - .setPriority(mapPriorityToAndroid(notification.priority)) - .setContentIntent(pendingIntent) - .setAutoCancel(true) - - notificationManager.notify(notification.notificationId, builder.build()) - } - - private fun mapPriorityToAndroid(priority: Int): Int { - return when (priority) { - 1 -> NotificationCompat.PRIORITY_MIN - 2 -> NotificationCompat.PRIORITY_LOW - 3 -> NotificationCompat.PRIORITY_DEFAULT - 4 -> NotificationCompat.PRIORITY_HIGH - 5 -> NotificationCompat.PRIORITY_MAX - else -> NotificationCompat.PRIORITY_DEFAULT - } - } - private fun createNotificationChannel() { val channel = NotificationChannel( CHANNEL_ID, @@ -216,15 +98,4 @@ class SignalNotificationListener : NotificationListenerService() { val notificationManager = getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager notificationManager.createNotificationChannel(channel) } - - private fun getAppPackageFromToken(token: String): String { - return token.split(":").firstOrNull() ?: "" - } - - private data class NotificationData( - val title: String?, - val body: String, - val priority: Int, - val clickUrl: String? - ) } diff --git a/android/app/src/main/java/com/lonecloud/sup/db/Database.kt b/android/app/src/main/java/com/lonecloud/sup/db/Database.kt index dbeaaf7..c7decf3 100644 --- a/android/app/src/main/java/com/lonecloud/sup/db/Database.kt +++ b/android/app/src/main/java/com/lonecloud/sup/db/Database.kt @@ -372,6 +372,19 @@ interface SubscriptionDao { """) fun getByConnectorToken(connectorToken: String): SubscriptionWithMetadata? + @Query(""" + SELECT + s.id, s.baseUrl, s.topic, s.mutedUntil, s.minPriority, s.autoDelete, s.insistent, s.upAppId, s.upConnectorToken, s.displayName, + COUNT(n.id) totalCount, + COUNT(CASE n.notificationId WHEN 0 THEN NULL ELSE n.id END) newCount, + IFNULL(MAX(n.timestamp),0) AS lastActive + FROM Subscription AS s + LEFT JOIN Notification AS n ON s.id=n.subscriptionId AND n.deleted != 1 + WHERE s.upAppId = :upAppId + GROUP BY s.id + """) + fun getByUpAppId(upAppId: String): SubscriptionWithMetadata? + @Insert fun add(subscription: Subscription) diff --git a/android/app/src/main/java/com/lonecloud/sup/db/Repository.kt b/android/app/src/main/java/com/lonecloud/sup/db/Repository.kt index a510692..258c2ea 100644 --- a/android/app/src/main/java/com/lonecloud/sup/db/Repository.kt +++ b/android/app/src/main/java/com/lonecloud/sup/db/Repository.kt @@ -147,26 +147,6 @@ class Repository(private val sharedPrefs: SharedPreferences, database: Database) notificationDao.removeAll(subscriptionId) } - fun getDeleteWorkerVersion(): Int { - return sharedPrefs.getInt(SHARED_PREFS_DELETE_WORKER_VERSION, 0) - } - - fun setDeleteWorkerVersion(version: Int) { - sharedPrefs.edit { - putInt(SHARED_PREFS_DELETE_WORKER_VERSION, version) - } - } - - fun getAutoRestartWorkerVersion(): Int { - return sharedPrefs.getInt(SHARED_PREFS_AUTO_RESTART_WORKER_VERSION, 0) - } - - fun setAutoRestartWorkerVersion(version: Int) { - sharedPrefs.edit { - putInt(SHARED_PREFS_AUTO_RESTART_WORKER_VERSION, version) - } - } - fun setMinPriority(minPriority: Int) { if (minPriority <= MIN_PRIORITY_ANY) { sharedPrefs.edit { @@ -432,8 +412,6 @@ class Repository(private val sharedPrefs: SharedPreferences, database: Database) companion object { const val SHARED_PREFS_ID = "MainPreferences" - const val SHARED_PREFS_DELETE_WORKER_VERSION = "DeleteWorkerVersion" - const val SHARED_PREFS_AUTO_RESTART_WORKER_VERSION = "AutoRestartWorkerVersion" const val SHARED_PREFS_MUTED_UNTIL_TIMESTAMP = "MutedUntil" const val SHARED_PREFS_MIN_PRIORITY = "MinPriority" const val SHARED_PREFS_AUTO_DOWNLOAD_MAX_SIZE = "AutoDownload" diff --git a/android/app/src/main/java/com/lonecloud/sup/msg/NotificationService.kt b/android/app/src/main/java/com/lonecloud/sup/msg/NotificationService.kt index af49613..e49a4a9 100644 --- a/android/app/src/main/java/com/lonecloud/sup/msg/NotificationService.kt +++ b/android/app/src/main/java/com/lonecloud/sup/msg/NotificationService.kt @@ -13,7 +13,7 @@ import com.lonecloud.sup.R import com.lonecloud.sup.db.* import com.lonecloud.sup.db.Notification import com.lonecloud.sup.ui.Colors -import com.lonecloud.sup.ui.DetailActivity + import com.lonecloud.sup.ui.MainActivity import com.lonecloud.sup.util.* import java.util.* @@ -99,7 +99,14 @@ class NotificationService(val context: Context) { } private fun setClickAction(builder: NotificationCompat.Builder, subscription: Subscription) { - builder.setContentIntent(detailActivityIntent(subscription)) + val intent = Intent(context, MainActivity::class.java) + val pendingIntent = PendingIntent.getActivity( + context, + Random.nextInt(), + intent, + PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT + ) + builder.setContentIntent(pendingIntent) } private fun subscriptionGroupName(subscription: Subscription): String { @@ -118,20 +125,6 @@ class NotificationService(val context: Context) { } } - private fun detailActivityIntent(subscription: Subscription): PendingIntent? { - val intent = Intent(context, DetailActivity::class.java).apply { - putExtra(MainActivity.EXTRA_SUBSCRIPTION_ID, subscription.id) - putExtra(MainActivity.EXTRA_SUBSCRIPTION_BASE_URL, subscription.baseUrl) - putExtra(MainActivity.EXTRA_SUBSCRIPTION_TOPIC, subscription.topic) - putExtra(MainActivity.EXTRA_SUBSCRIPTION_DISPLAY_NAME, displayName(appBaseUrl, subscription)) - putExtra(MainActivity.EXTRA_SUBSCRIPTION_MUTED_UNTIL, subscription.mutedUntil) - } - return TaskStackBuilder.create(context).run { - addNextIntentWithParentStack(intent) - getPendingIntent(Random().nextInt(), PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE) - } - } - private fun maybeCreateNotificationChannel(group: String, priority: Int) { val channelId = toChannelId(group, priority) val pause = 300L diff --git a/android/app/src/main/java/com/lonecloud/sup/service/SignalListenerService.kt b/android/app/src/main/java/com/lonecloud/sup/service/SignalListenerService.kt index 3cea5a4..b443ca3 100644 --- a/android/app/src/main/java/com/lonecloud/sup/service/SignalListenerService.kt +++ b/android/app/src/main/java/com/lonecloud/sup/service/SignalListenerService.kt @@ -36,7 +36,6 @@ class SignalListenerService : Service() { override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { Log.d(TAG, "Service started") - // Process notification from intent if present intent?.getStringExtra(EXTRA_NOTIFICATION_DATA)?.let { data -> scope.launch { processNotification(data) @@ -70,10 +69,7 @@ class SignalListenerService : Service() { private suspend fun processNotification(data: String) { try { - // Parse notification data and dispatch Log.d(TAG, "Processing notification: $data") - // This will be called by SignalNotificationListener - // with parsed notification data } catch (e: Exception) { Log.e(TAG, "Error processing notification: ${e.message}", e) } diff --git a/android/app/src/main/java/com/lonecloud/sup/ui/DetailActivity.kt b/android/app/src/main/java/com/lonecloud/sup/ui/DetailActivity.kt deleted file mode 100644 index 5ca66e2..0000000 --- a/android/app/src/main/java/com/lonecloud/sup/ui/DetailActivity.kt +++ /dev/null @@ -1,877 +0,0 @@ -package com.lonecloud.sup.ui - -import android.app.AlertDialog -import android.content.Intent -import android.content.Intent.ACTION_VIEW -import android.net.Uri -import android.os.Bundle -import android.text.Html -import android.view.Menu -import android.view.MenuItem -import android.view.View -import android.widget.TextView -import android.widget.Toast -import androidx.activity.enableEdgeToEdge -import androidx.activity.viewModels -import androidx.appcompat.app.AppCompatActivity -import androidx.appcompat.view.ActionMode -import androidx.core.content.ContextCompat -import androidx.core.view.ViewCompat -import androidx.core.view.WindowInsetsCompat -import androidx.core.view.WindowInsetsControllerCompat -import androidx.core.view.isVisible -import androidx.core.view.updatePadding -import androidx.lifecycle.lifecycleScope -import androidx.recyclerview.widget.ItemTouchHelper -import androidx.recyclerview.widget.RecyclerView -import androidx.swiperefreshlayout.widget.SwipeRefreshLayout -import com.google.android.material.dialog.MaterialAlertDialogBuilder -import com.google.android.material.snackbar.Snackbar -import com.lonecloud.sup.BuildConfig -import com.lonecloud.sup.R -import com.lonecloud.sup.app.Application -import com.lonecloud.sup.db.Notification -import com.lonecloud.sup.db.Repository -import com.lonecloud.sup.db.Subscription -import com.lonecloud.sup.msg.ApiService -import com.lonecloud.sup.msg.NotificationService -import com.lonecloud.sup.util.Log -import com.lonecloud.sup.util.copyToClipboard -import com.lonecloud.sup.util.dangerButton -import com.lonecloud.sup.util.decodeMessage -import com.lonecloud.sup.util.displayName -import com.lonecloud.sup.util.formatDateShort -import com.lonecloud.sup.util.isDarkThemeOn -import com.lonecloud.sup.util.randomSubscriptionId -import com.lonecloud.sup.util.topicShortUrl -import com.lonecloud.sup.util.topicUrl -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.GlobalScope -import kotlinx.coroutines.delay -import kotlinx.coroutines.isActive -import kotlinx.coroutines.launch -import java.util.Date -import kotlin.random.Random -import androidx.core.view.size -import androidx.core.view.get -import androidx.core.net.toUri -import com.google.android.material.floatingactionbutton.FloatingActionButton -import com.google.android.material.textfield.TextInputEditText -import android.widget.ImageButton - -class DetailActivity : AppCompatActivity() { - private val viewModel by viewModels { - DetailViewModelFactory((application as Application).repository) - } - private val repository by lazy { (application as Application).repository } - private val api by lazy { ApiService(this) } - private var notifier: NotificationService? = null // Context-dependent - private var appBaseUrl: String? = null // Context-dependent - - // Which subscription are we looking at - private var subscriptionId: Long = 0L // Set in onCreate() - private var subscriptionBaseUrl: String = "" // Set in onCreate() - private var subscriptionTopic: String = "" // Set in onCreate() - private var subscriptionDisplayName: String = "" // Set in onCreate() & updated by options menu! - private var subscriptionMutedUntil: Long = 0L // Set in onCreate() & updated by options menu! - - // UI elements - private lateinit var adapter: DetailAdapter - private lateinit var mainList: RecyclerView - private lateinit var mainListContainer: SwipeRefreshLayout - private lateinit var menu: Menu - private lateinit var fab: FloatingActionButton - private lateinit var messageBar: View - private lateinit var messageBarText: TextInputEditText - private lateinit var messageBarPublishButton: FloatingActionButton - private lateinit var messageBarExpandButton: ImageButton - - // Action mode stuff - private var actionMode: ActionMode? = null - private val actionModeCallback = object : ActionMode.Callback { - override fun onCreateActionMode(mode: ActionMode?, menu: Menu?): Boolean { - actionMode = mode - if (mode != null) { - mode.menuInflater.inflate(R.menu.menu_detail_action_mode, menu) - mode.title = "1" // One item selected - } - return true - } - - override fun onPrepareActionMode(mode: ActionMode?, menu: Menu?) = false - - override fun onActionItemClicked(mode: ActionMode?, item: MenuItem): Boolean { - return when (item.itemId) { - R.id.detail_action_mode_copy -> { - onMultiCopyClick() - true - } - R.id.detail_action_mode_delete -> { - onMultiDeleteClick() - true - } - else -> false - } - } - - override fun onDestroyActionMode(mode: ActionMode?) { - endActionModeAndRedraw() - } - } - - override fun onCreate(savedInstanceState: Bundle?) { - enableEdgeToEdge() - super.onCreate(savedInstanceState) - setContentView(R.layout.activity_detail) - - Log.d(TAG, "Create $this") - - // Dependencies that depend on Context - notifier = NotificationService(this) - appBaseUrl = getString(R.string.app_base_url) - - val toolbarLayout = findViewById(R.id.app_bar_drawer) - val dynamicColors = repository.getDynamicColorsEnabled() - val darkMode = isDarkThemeOn(this) - val statusBarColor = Colors.statusBarNormal(this, dynamicColors, darkMode) - val toolbarTextColor = Colors.toolbarTextColor(this, dynamicColors, darkMode) - toolbarLayout.setBackgroundColor(statusBarColor) - - val toolbar = toolbarLayout.findViewById(R.id.toolbar) - toolbar.setTitleTextColor(toolbarTextColor) - toolbar.setNavigationIconTint(toolbarTextColor) - toolbar.overflowIcon?.setTint(toolbarTextColor) - setSupportActionBar(toolbar) - - // Set system status bar appearance - WindowInsetsControllerCompat(window, window.decorView).isAppearanceLightStatusBars = - Colors.shouldUseLightStatusBar(dynamicColors, darkMode) - - // Set detail activity background: use theme background for dynamic colors, static gray for non-dynamic - val detailContentLayout = findViewById(R.id.detail_content_layout) - if (repository.getDynamicColorsEnabled()) { - detailContentLayout.setBackgroundColor( - com.google.android.material.color.MaterialColors.getColor( - this, - android.R.attr.colorBackground, - ContextCompat.getColor(this, R.color.detail_activity_background) - ) - ) - } else { - detailContentLayout.setBackgroundColor( - ContextCompat.getColor(this, R.color.detail_activity_background) - ) - } - - // Show 'Back' button - supportActionBar?.setDisplayHomeAsUpEnabled(true) - - // Hide links that lead to payments, see https://github.com/binwiederhier/ntfy/issues/1463 - val howToLink = findViewById(R.id.detail_how_to_link) - howToLink.isVisible = BuildConfig.PAYMENT_LINKS_AVAILABLE - - // Handle direct deep links to topic "ntfy://..." - val url = intent?.data - if (intent?.action == ACTION_VIEW && url != null) { - maybeSubscribeAndLoadView(url) - } else { - loadView() - } - } - - private fun maybeSubscribeAndLoadView(url: Uri) { - if (url.pathSegments.size != 1) { - Log.w(TAG, "Invalid link $url. Aborting.") - finish() - return - } - val secure = url.getBooleanQueryParameter("secure", true) // Default to https:// - val displayName = url.getQueryParameter("display") - val baseUrl = extractBaseUrl(url, secure) - val topic = url.pathSegments.first() - - title = topicShortUrl(baseUrl, topic) - - // Subscribe to topic if it doesn't already exist - lifecycleScope.launch(Dispatchers.IO) { - var subscription = repository.getSubscription(baseUrl, topic) - if (subscription == null) { - subscription = Subscription( - id = randomSubscriptionId(), - baseUrl = baseUrl, - topic = topic, - mutedUntil = 0, - minPriority = Repository.MIN_PRIORITY_USE_GLOBAL, - autoDelete = Repository.AUTO_DELETE_USE_GLOBAL, - insistent = Repository.INSISTENT_MAX_PRIORITY_USE_GLOBAL, - upAppId = null, - upConnectorToken = null, - displayName = displayName, - totalCount = 0, - newCount = 0, - lastActive = Date().time/1000 - ) - repository.addSubscription(subscription) - - - // Fetch cached messages - try { - val notifications = api.poll(subscription.id, subscription.baseUrl, subscription.topic) - notifications.forEach { notification -> repository.addNotification(notification) } - } catch (e: Exception) { - Log.e(TAG, "Unable to fetch notifications: ${e.message}", e) - } - - runOnUiThread { - val message = getString(R.string.detail_deep_link_subscribed_toast_message, topicShortUrl(baseUrl, topic)) - Toast.makeText(this@DetailActivity, message, Toast.LENGTH_LONG).show() - } - } - - // Add extras needed in loadView(); normally these are added in MainActivity - intent.putExtra(MainActivity.EXTRA_SUBSCRIPTION_ID, subscription.id) - intent.putExtra(MainActivity.EXTRA_SUBSCRIPTION_BASE_URL, subscription.baseUrl) - intent.putExtra(MainActivity.EXTRA_SUBSCRIPTION_TOPIC, subscription.topic) - intent.putExtra(MainActivity.EXTRA_SUBSCRIPTION_DISPLAY_NAME, displayName(appBaseUrl, subscription)) - intent.putExtra(MainActivity.EXTRA_SUBSCRIPTION_MUTED_UNTIL, subscription.mutedUntil) - - runOnUiThread { - loadView() - } - } - } - - fun extractBaseUrl(url: Uri, secure: Boolean): String { - if (secure) { - return if (url.port != 443 && url.port != -1) "https://${url.host}:${url.port}" else "https://${url.host}" - } - return if (url.port != 80 && url.port != -1) "http://${url.host}:${url.port}" else "http://${url.host}" - } - - private fun loadView() { - // Get extras required for the return to the main activity - subscriptionId = intent.getLongExtra(MainActivity.EXTRA_SUBSCRIPTION_ID, 0) - subscriptionBaseUrl = intent.getStringExtra(MainActivity.EXTRA_SUBSCRIPTION_BASE_URL) ?: return - subscriptionTopic = intent.getStringExtra(MainActivity.EXTRA_SUBSCRIPTION_TOPIC) ?: return - subscriptionDisplayName = intent.getStringExtra(MainActivity.EXTRA_SUBSCRIPTION_DISPLAY_NAME) ?: return - subscriptionMutedUntil = intent.getLongExtra(MainActivity.EXTRA_SUBSCRIPTION_MUTED_UNTIL, 0L) - - // Set title - val subscriptionBaseUrl = intent.getStringExtra(MainActivity.EXTRA_SUBSCRIPTION_BASE_URL) ?: return - val topicUrl = topicShortUrl(subscriptionBaseUrl, subscriptionTopic) - title = subscriptionDisplayName - - // Set "how to instructions" - val howToExample: TextView = findViewById(R.id.detail_how_to_example) - howToExample.linksClickable = true - - val howToText = getString(R.string.detail_how_to_example, topicUrl) - howToExample.text = Html.fromHtml(howToText, Html.FROM_HTML_MODE_LEGACY) - - // Swipe to refresh - mainListContainer = findViewById(R.id.detail_notification_list_container) - mainListContainer.setOnRefreshListener { refresh() } - mainListContainer.setColorSchemeColors(Colors.swipeToRefreshColor(this)) - - // Update main list based on viewModel (& its datasource/livedata) - val noEntriesText: View = findViewById(R.id.detail_no_notifications) - val onNotificationClick = { n: Notification -> onNotificationClick(n) } - val onNotificationLongClick = { n: Notification -> onNotificationLongClick(n) } - - adapter = DetailAdapter(this, lifecycleScope, repository, onNotificationClick, onNotificationLongClick) - mainList = findViewById(R.id.detail_notification_list) - mainList.adapter = adapter - - // Apply window insets to ensure content is not covered by navigation bar - mainList.clipToPadding = false - ViewCompat.setOnApplyWindowInsetsListener(mainList) { v, insets -> - val systemBars = insets.getInsets(WindowInsetsCompat.Type.systemBars()) - v.updatePadding(bottom = systemBars.bottom) - insets - } - - viewModel.list(subscriptionId).observe(this) { - it?.let { - // Show list view - adapter.submitList(it as MutableList) - if (it.isEmpty()) { - mainListContainer.visibility = View.GONE - noEntriesText.visibility = View.VISIBLE - } else { - mainListContainer.visibility = View.VISIBLE - noEntriesText.visibility = View.GONE - } - - // Cancel notifications that still have popups - maybeCancelNotificationPopups(it) - } - } - - // Swipe to remove - val itemTouchCallback = object : ItemTouchHelper.SimpleCallback(0, ItemTouchHelper.LEFT or ItemTouchHelper.RIGHT) { - override fun onMove(recyclerView: RecyclerView, viewHolder: RecyclerView.ViewHolder, target: RecyclerView.ViewHolder): Boolean { - return false - } - override fun onSwiped(viewHolder: RecyclerView.ViewHolder, swipeDir: Int) { - val notification = adapter.get(viewHolder.absoluteAdapterPosition) - lifecycleScope.launch(Dispatchers.IO) { - repository.markAsDeleted(notification.id) - } - val snackbar = Snackbar.make(mainList, R.string.detail_item_snack_deleted, Snackbar.LENGTH_SHORT) - snackbar.setAction(R.string.detail_item_snack_undo) { - lifecycleScope.launch(Dispatchers.IO) { - repository.undeleteNotification(notification.id) - } - } - snackbar.show() - } - } - val itemTouchHelper = ItemTouchHelper(itemTouchCallback) - itemTouchHelper.attachToRecyclerView(mainList) - - // Scroll up when new notification is added - adapter.registerAdapterDataObserver(object : RecyclerView.AdapterDataObserver() { - override fun onItemRangeInserted(positionStart: Int, itemCount: Int) { - if (positionStart == 0) { - Log.d(TAG, "$itemCount item(s) inserted at 0, scrolling to the top") - mainList.scrollToPosition(positionStart) - } - } - }) - - // React to changes in fast delivery setting - repository.getSubscriptionIdsWithInstantStatusLiveData().observe(this) { - // Signal pushes to us, no service to refresh - } - - // Observe connection details and update menu item visibility - repository.getConnectionDetailsLiveData().observe(this) { details -> - showHideConnectionErrorMenuItem(details) - } - - // Mark this subscription as "open" so we don't receive notifications for it - repository.detailViewSubscriptionId.set(subscriptionId) - - // Stop insistent playback (if running, otherwise it'll throw) - try { - repository.mediaPlayer.stop() - } catch (_: Exception) { - // Ignore errors - } - - // Setup FAB and message bar - setupPublishUI() - } - - private fun setupPublishUI() { - fab = findViewById(R.id.detail_fab) - messageBar = findViewById(R.id.detail_message_bar) - messageBarText = messageBar.findViewById(R.id.message_bar_text) - messageBarPublishButton = messageBar.findViewById(R.id.message_bar_publish_button) - messageBarExpandButton = messageBar.findViewById(R.id.message_bar_expand_button) - - // Message bar enabled: Show message bar, hide FAB - if (repository.getMessageBarEnabled()) { - fab.visibility = View.GONE - messageBar.visibility = View.VISIBLE - - // Send button click - messageBarPublishButton.setOnClickListener { - publishMessage(messageBarText.text.toString()) // Allow publishing empty messages - } - - // Expand button click opens the full dialog - messageBarExpandButton.setOnClickListener { - openPublishDialog(messageBarText.text.toString()) - } - - // Handle window insets for navigation bar and keyboard - val contentLayout = findViewById(R.id.detail_content_layout) - ViewCompat.setOnApplyWindowInsetsListener(contentLayout) { view, insets -> - val systemBars = insets.getInsets(WindowInsetsCompat.Type.systemBars()) - val ime = insets.getInsets(WindowInsetsCompat.Type.ime()) - // Use the larger of navigation bar or keyboard height - val bottomPadding = maxOf(systemBars.bottom, ime.bottom) - view.setPadding(view.paddingLeft, view.paddingTop, view.paddingRight, bottomPadding) - insets - } - } else { - // Show FAB, hide message bar - fab.visibility = View.VISIBLE - messageBar.visibility = View.GONE - - fab.setOnClickListener { - openPublishDialog("") - } - - // Add bottom padding to FAB to account for navigation bar - ViewCompat.setOnApplyWindowInsetsListener(fab) { view, insets -> - val systemBars = insets.getInsets(WindowInsetsCompat.Type.systemBars()) - val layoutParams = view.layoutParams as androidx.coordinatorlayout.widget.CoordinatorLayout.LayoutParams - layoutParams.bottomMargin = systemBars.bottom + resources.getDimensionPixelSize(R.dimen.fab_margin) - view.layoutParams = layoutParams - insets - } - } - } - - private fun openPublishDialog(initialMessage: String) { - // Publishing dialog removed - feature not implemented - Log.d(TAG, "Publishing dialog not available") - } - - private fun publishMessage(message: String) { - // Disable send button while publishing - messageBarPublishButton.isEnabled = false - - lifecycleScope.launch(Dispatchers.IO) { - try { - api.publish( - baseUrl = subscriptionBaseUrl, - topic = subscriptionTopic, - message = message, - title = "", - priority = 3, // Default priority - tags = emptyList(), - delay = "" - ) - runOnUiThread { - messageBarText.text?.clear() - messageBarPublishButton.isEnabled = true - } - } catch (e: Exception) { - Log.w(TAG, "Failed to publish message", e) - runOnUiThread { - messageBarPublishButton.isEnabled = true - val errorMessage = when (e) { - is ApiService.UnauthorizedException -> { - getString(R.string.detail_test_message_error_unauthorized_anon) - } - is ApiService.EntityTooLargeException -> { - getString(R.string.detail_test_message_error_too_large) - } - is ApiService.ApiException -> { - getString(R.string.publish_dialog_error_server, e.error, e.code) - } - else -> { - getString(R.string.publish_dialog_error_sending, e.message) - } - } - Toast.makeText(this@DetailActivity, errorMessage, Toast.LENGTH_LONG).show() - } - } - } - } - - override fun onResume() { - super.onResume() - - // Mark as "open" so we don't send notifications while this is open - repository.detailViewSubscriptionId.set(subscriptionId) - - // Update buttons (this is for when we return from the preferences screen) - lifecycleScope.launch(Dispatchers.IO) { - val subscription = repository.getSubscription(subscriptionId) ?: return@launch - subscriptionMutedUntil = subscription.mutedUntil - subscriptionDisplayName = displayName(appBaseUrl, subscription) - - showHideMutedUntilMenuItems(subscriptionMutedUntil) - showHideCopyMenuItems(subscription.baseUrl) - showHideConnectionErrorMenuItem(repository.getConnectionDetails()) - updateTitle(subscriptionDisplayName) - } - } - - override fun onPause() { - super.onPause() - Log.d(TAG, "onPause hook: Removing 'notificationId' from all notifications for $subscriptionId") - GlobalScope.launch(Dispatchers.IO) { - // Note: This is here and not in onDestroy/onStop, because we want to clear notifications as early - // as possible, so that we don't see the "new" bubble in the main list anymore. - repository.clearAllNotificationIds(subscriptionId) - } - Log.d(TAG, "onPause hook: Marking subscription $subscriptionId as 'not open'") - repository.detailViewSubscriptionId.set(0) // Mark as closed - } - - private fun maybeCancelNotificationPopups(notifications: List) { - val notificationsWithPopups = notifications.filter { notification -> notification.notificationId != 0 } - if (notificationsWithPopups.isNotEmpty()) { - lifecycleScope.launch(Dispatchers.IO) { - notificationsWithPopups.forEach { notification -> - notifier?.cancel(notification.notificationId) - // Do NOT remove the notificationId here, we need that for the UI indicators; we'll remove it in onPause() - } - } - } - } - - override fun onCreateOptionsMenu(menu: Menu): Boolean { - menuInflater.inflate(R.menu.menu_detail_action_bar, menu) - this.menu = menu - - // Tint menu icons based on theme - val toolbarTextColor = Colors.toolbarTextColor(this, repository.getDynamicColorsEnabled(), isDarkThemeOn(this)) - for (i in 0 until menu.size) { - menu[i].icon?.setTint(toolbarTextColor) - } - - // Show and hide buttons - showHideMutedUntilMenuItems(subscriptionMutedUntil) - showHideCopyMenuItems(subscriptionBaseUrl) - showHideConnectionErrorMenuItem(repository.getConnectionDetails()) - - // Regularly check if "notification muted" time has passed - // NOTE: This is done here, because then we know that we've initialized the menu items. - startNotificationMutedChecker() - - return true - } - - private fun startNotificationMutedChecker() { - // FIXME This is awful and has to go. - - lifecycleScope.launch(Dispatchers.IO) { - delay(1000) // Just to be sure we've initialized all the things, we wait a bit ... - while (isActive) { - Log.d(TAG, "Checking 'muted until' timestamp for subscription $subscriptionId") - val subscription = repository.getSubscription(subscriptionId) ?: return@launch - val mutedUntilExpired = subscription.mutedUntil > 1L && System.currentTimeMillis()/1000 > subscription.mutedUntil - if (mutedUntilExpired) { - val newSubscription = subscription.copy(mutedUntil = 0L) - repository.updateSubscription(newSubscription) - showHideMutedUntilMenuItems(0L) - } - delay(60_000) - } - } - } - - override fun onOptionsItemSelected(item: MenuItem): Boolean { - return when (item.itemId) { - R.id.detail_menu_test -> { - onTestClick() - true - } - R.id.detail_menu_notifications_enabled -> { - onMutedUntilClick(enable = false) - true - } - R.id.detail_menu_notifications_disabled_until -> { - onMutedUntilClick(enable = true) - true - } - R.id.detail_menu_notifications_disabled_forever -> { - onMutedUntilClick(enable = true) - true - } - R.id.detail_menu_connection_error -> { - onConnectionErrorClick() - true - } - R.id.detail_menu_copy_url -> { - onCopyUrlClick() - true - } - R.id.detail_menu_clear -> { - onClearClick() - true - } - R.id.detail_menu_settings -> { - onSettingsClick() - true - } - R.id.detail_menu_unsubscribe -> { - onDeleteClick() - true - } - else -> super.onOptionsItemSelected(item) - } - } - - private fun onTestClick() { - Log.d(TAG, "Sending test notification to ${topicShortUrl(subscriptionBaseUrl, subscriptionTopic)}") - - lifecycleScope.launch(Dispatchers.IO) { - try { - val possibleTags = listOf( - "warning", "skull", "success", "triangular_flag_on_post", "de", "dog", "rotating_light", "cat", "bike", // Emojis - "backup", "rsync", "de-server1", "this-is-a-tag" - ) - val priority = Random.nextInt(1, 6) - val tags = possibleTags.shuffled().take(Random.nextInt(0, 4)) - val title = if (Random.nextBoolean()) getString(R.string.detail_test_title) else "" - val message = getString(R.string.detail_test_message, priority) - api.publish(subscriptionBaseUrl, subscriptionTopic, message, title, priority, tags, delay = "") - } catch (e: Exception) { - runOnUiThread { - val message = if (e is ApiService.UnauthorizedException) { - getString(R.string.detail_test_message_error_unauthorized_anon) - } else { - getString(R.string.detail_test_message_error, e.message) - } - Toast - .makeText(this@DetailActivity, message, Toast.LENGTH_LONG) - .show() - } - } - } - } - - private fun onMutedUntilClick(enable: Boolean) { - if (!enable) { - Log.d(TAG, "Notification settings dialog not available") - } else { - Log.d(TAG, "Re-enabling notifications ${topicShortUrl(subscriptionBaseUrl, subscriptionTopic)}") - onNotificationMutedUntilChanged(Repository.MUTED_UNTIL_SHOW_ALL) - } - } - - private fun onConnectionErrorClick() { - Log.d(TAG, "Connection error dialog not available") - } - - fun onNotificationMutedUntilChanged(mutedUntilTimestamp: Long) { - lifecycleScope.launch(Dispatchers.IO) { - Log.d(TAG, "Setting subscription 'muted until' to $mutedUntilTimestamp") - val subscription = repository.getSubscription(subscriptionId) - val newSubscription = subscription?.copy(mutedUntil = mutedUntilTimestamp) - newSubscription?.let { repository.updateSubscription(newSubscription) } - subscriptionMutedUntil = mutedUntilTimestamp - showHideMutedUntilMenuItems(mutedUntilTimestamp) - runOnUiThread { - when (mutedUntilTimestamp) { - 0L -> Toast.makeText(this@DetailActivity, getString(R.string.notification_dialog_enabled_toast_message), Toast.LENGTH_LONG).show() - 1L -> Toast.makeText(this@DetailActivity, getString(R.string.notification_dialog_muted_forever_toast_message), Toast.LENGTH_LONG).show() - else -> { - val formattedDate = formatDateShort(mutedUntilTimestamp) - Toast.makeText(this@DetailActivity, getString(R.string.notification_dialog_muted_until_toast_message, formattedDate), Toast.LENGTH_LONG).show() - } - } - } - } - } - - private fun onCopyUrlClick() { - val url = topicUrl(subscriptionBaseUrl, subscriptionTopic) - Log.d(TAG, "Copying topic URL $url to clipboard ") - - runOnUiThread { - copyToClipboard(this, "topic address", url) - } - } - - private fun refresh() { - Log.d(TAG, "Fetching cached notifications for ${topicShortUrl(subscriptionBaseUrl, subscriptionTopic)}") - - lifecycleScope.launch(Dispatchers.IO) { - try { - val subscription = repository.getSubscription(subscriptionId) ?: return@launch - val notifications = api.poll(subscription.id, subscription.baseUrl, subscription.topic, null) - val newNotifications = repository.onlyNewNotifications(subscriptionId, notifications) - val toastMessage = if (newNotifications.isEmpty()) { - getString(R.string.refresh_message_no_results) - } else { - getString(R.string.refresh_message_result, newNotifications.size) - } - newNotifications.forEach { notification -> repository.addNotification(notification) } - runOnUiThread { - Toast.makeText(this@DetailActivity, toastMessage, Toast.LENGTH_LONG).show() - mainListContainer.isRefreshing = false - } - } catch (e: Exception) { - Log.e(TAG, "Error fetching notifications for ${topicShortUrl(subscriptionBaseUrl, subscriptionTopic)}: ${e.stackTrace}", e) - runOnUiThread { - Toast - .makeText(this@DetailActivity, getString(R.string.refresh_message_error_one, e.message), Toast.LENGTH_LONG) - .show() - mainListContainer.isRefreshing = false - } - } - } - } - - private fun showHideMutedUntilMenuItems(mutedUntilTimestamp: Long) { - if (!this::menu.isInitialized) { - return - } - subscriptionMutedUntil = mutedUntilTimestamp - runOnUiThread { - val notificationsEnabledItem = menu.findItem(R.id.detail_menu_notifications_enabled) - val notificationsDisabledUntilItem = menu.findItem(R.id.detail_menu_notifications_disabled_until) - val notificationsDisabledForeverItem = menu.findItem(R.id.detail_menu_notifications_disabled_forever) - notificationsEnabledItem?.isVisible = subscriptionMutedUntil == 0L - notificationsDisabledForeverItem?.isVisible = subscriptionMutedUntil == 1L - notificationsDisabledUntilItem?.isVisible = subscriptionMutedUntil > 1L - if (subscriptionMutedUntil > 1L) { - val formattedDate = formatDateShort(subscriptionMutedUntil) - notificationsDisabledUntilItem?.title = getString(R.string.detail_menu_notifications_disabled_until, formattedDate) - } - } - } - - - private fun showHideCopyMenuItems(subscriptionBaseUrl: String) { - if (!this::menu.isInitialized) { - return - } - runOnUiThread { - // Hide links that lead to payments, see https://github.com/binwiederhier/ntfy/issues/1463 - val copyUrlItem = menu.findItem(R.id.detail_menu_copy_url) - copyUrlItem?.isVisible = appBaseUrl != subscriptionBaseUrl || BuildConfig.PAYMENT_LINKS_AVAILABLE - } - } - - private fun showHideConnectionErrorMenuItem(details: Map) { - if (!this::menu.isInitialized) { - return - } - runOnUiThread { - val connectionErrorItem = menu.findItem(R.id.detail_menu_connection_error) - // Only show if there's an error for this subscription's base URL - val hasError = details[subscriptionBaseUrl]?.hasError() == true - connectionErrorItem?.isVisible = hasError - } - } - - private fun updateTitle(subscriptionDisplayName: String) { - runOnUiThread { - title = subscriptionDisplayName - } - } - - private fun onClearClick() { - Log.d(TAG, "Clearing all notifications for ${topicShortUrl(subscriptionBaseUrl, subscriptionTopic)}") - - val dialog = MaterialAlertDialogBuilder(this) - .setMessage(R.string.detail_clear_dialog_message) - .setPositiveButton(R.string.detail_clear_dialog_permanently_delete) { _, _ -> - lifecycleScope.launch(Dispatchers.IO) { - repository.markAllAsDeleted(subscriptionId) - } - } - .setNegativeButton(R.string.detail_clear_dialog_cancel) { _, _ -> /* Do nothing */ } - .create() - dialog.setOnShowListener { - dialog - .getButton(AlertDialog.BUTTON_POSITIVE) - .dangerButton() - } - dialog.show() - } - - private fun onSettingsClick() { - Log.d(TAG, "Settings not available") - } - - private fun onDeleteClick() { - Log.d(TAG, "Deleting subscription ${topicShortUrl(subscriptionBaseUrl, subscriptionTopic)}") - - val dialog = MaterialAlertDialogBuilder(this) - .setMessage(R.string.detail_delete_dialog_message) - .setPositiveButton(R.string.detail_delete_dialog_permanently_delete) { _, _ -> - Log.d(TAG, "Deleting subscription with subscription ID $subscriptionId (topic: $subscriptionTopic)") - GlobalScope.launch(Dispatchers.IO) { - repository.removeAllNotifications(subscriptionId) - repository.removeSubscription(subscriptionId) - // Signal pushes to us, no Firebase to unsubscribe from - } - finish() - } - .setNegativeButton(R.string.detail_delete_dialog_cancel) { _, _ -> /* Do nothing */ } - .create() - dialog.setOnShowListener { - dialog - .getButton(AlertDialog.BUTTON_POSITIVE) - .dangerButton() - } - dialog.show() - } - - private fun onNotificationClick(notification: Notification) { - if (actionMode != null) { - handleActionModeClick(notification) - } else { - runOnUiThread { - copyToClipboard(this, "notification", decodeMessage(notification)) - } - } - } - - private fun onNotificationLongClick(notification: Notification) { - if (actionMode == null) { - beginActionMode(notification) - } - } - - private fun handleActionModeClick(notification: Notification) { - adapter.toggleSelection(notification.id) - if (adapter.selected.size == 0) { - finishActionMode() - } else { - actionMode!!.title = adapter.selected.size.toString() - } - } - - private fun onMultiCopyClick() { - Log.d(TAG, "Copying multiple notifications to clipboard") - - lifecycleScope.launch(Dispatchers.IO) { - val content = adapter.selected.joinToString("\n\n") { notificationId -> - val notification = repository.getNotification(notificationId) - notification?.let { - decodeMessage(it) + "\n" + Date(it.timestamp * 1000).toString() - }.orEmpty() - } - runOnUiThread { - copyToClipboard(this@DetailActivity, "notifications", content) - finishActionMode() - } - } - } - - private fun onMultiDeleteClick() { - Log.d(TAG, "Showing multi-delete dialog for selected items") - - val dialog = MaterialAlertDialogBuilder(this) - .setMessage(R.string.detail_action_mode_delete_dialog_message) - .setPositiveButton(R.string.detail_action_mode_delete_dialog_permanently_delete) { _, _ -> - adapter.selected.map { notificationId -> viewModel.markAsDeleted(notificationId) } - finishActionMode() - } - .setNegativeButton(R.string.detail_action_mode_delete_dialog_cancel) { _, _ -> - finishActionMode() - } - .create() - dialog.setOnShowListener { - dialog - .getButton(AlertDialog.BUTTON_POSITIVE) - .dangerButton() - } - dialog.show() - } - - private fun beginActionMode(notification: Notification) { - actionMode = startSupportActionMode(actionModeCallback) - adapter.toggleSelection(notification.id) - } - - private fun finishActionMode() { - actionMode?.finish() - endActionModeAndRedraw() - } - - private fun endActionModeAndRedraw() { - actionMode = null - adapter.selected.clear() - adapter.notifyItemRangeChanged(0, adapter.currentList.size) - } - - companion object { - const val TAG = "NtfyDetailActivity" - const val EXTRA_SUBSCRIPTION_ID = "subscriptionId" - const val EXTRA_SUBSCRIPTION_BASE_URL = "baseUrl" - const val EXTRA_SUBSCRIPTION_TOPIC = "topic" - const val EXTRA_SUBSCRIPTION_DISPLAY_NAME = "displayName" - } -} diff --git a/android/app/src/main/java/com/lonecloud/sup/ui/DetailAdapter.kt b/android/app/src/main/java/com/lonecloud/sup/ui/DetailAdapter.kt deleted file mode 100644 index 0106db4..0000000 --- a/android/app/src/main/java/com/lonecloud/sup/ui/DetailAdapter.kt +++ /dev/null @@ -1,245 +0,0 @@ -package com.lonecloud.sup.ui - -import android.Manifest -import android.app.Activity -import android.content.* -import android.content.pm.PackageManager -import android.graphics.Bitmap -import android.os.Build -import android.os.Environment -import android.provider.MediaStore -import android.text.util.Linkify -import android.view.LayoutInflater -import android.view.View -import android.view.ViewGroup -import android.widget.* -import androidx.cardview.widget.CardView -import androidx.constraintlayout.helper.widget.Flow -import androidx.constraintlayout.widget.ConstraintLayout -import androidx.core.app.ActivityCompat -import androidx.core.content.ContextCompat -import androidx.core.content.FileProvider -import androidx.core.view.allViews -import androidx.recyclerview.widget.DiffUtil -import androidx.recyclerview.widget.ListAdapter -import androidx.recyclerview.widget.RecyclerView -import com.bumptech.glide.Glide -import com.google.android.material.button.MaterialButton -import com.stfalcon.imageviewer.StfalconImageViewer -import com.lonecloud.sup.R -import com.lonecloud.sup.db.* -import com.lonecloud.sup.msg.NotificationService -import com.lonecloud.sup.util.* -import io.noties.markwon.Markwon -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.GlobalScope -import kotlinx.coroutines.launch -import me.saket.bettermovementmethod.BetterLinkMovementMethod -import androidx.core.net.toUri - -class DetailAdapter(private val activity: Activity, private val lifecycleScope: CoroutineScope, private val repository: Repository, private val onClick: (Notification) -> Unit, private val onLongClick: (Notification) -> Unit) : - ListAdapter(TopicDiffCallback) { - private val markwon: Markwon = MarkwonFactory.createForMessage(activity) - val selected = mutableSetOf() // Notification IDs - - /* Creates and inflates view and return TopicViewHolder. */ - override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): DetailViewHolder { - val view = LayoutInflater.from(parent.context) - .inflate(R.layout.fragment_detail_item, parent, false) - return DetailViewHolder(activity, lifecycleScope, repository, markwon, view, selected, onClick, onLongClick) - } - - /* Gets current topic and uses it to bind view. */ - override fun onBindViewHolder(holder: DetailViewHolder, position: Int) { - holder.bind(getItem(position)) - } - - fun get(position: Int): Notification { - return getItem(position) - } - - fun toggleSelection(notificationId: String) { - if (selected.contains(notificationId)) { - selected.remove(notificationId) - } else { - selected.add(notificationId) - } - - if (selected.isNotEmpty()) { - val listIds = currentList.map { notification -> notification.id } - val notificationPosition = listIds.indexOf(notificationId) - notifyItemChanged(notificationPosition) - } - } - - /* ViewHolder for Topic, takes in the inflated view and the onClick behavior. */ - class DetailViewHolder( - private val activity: Activity, - private val lifecycleScope: CoroutineScope, - private val repository: Repository, - private val markwon: Markwon, - itemView: View, - private val selected: Set, - val onClick: (Notification) -> Unit, - val onLongClick: (Notification) -> Unit - ) : - RecyclerView.ViewHolder(itemView) { - private var notification: Notification? = null - private val layout: View = itemView.findViewById(R.id.detail_item_layout) - private val cardView: CardView = itemView.findViewById(R.id.detail_item_card) - private val priorityImageView: ImageView = itemView.findViewById(R.id.detail_item_priority_image) - private val dateView: TextView = itemView.findViewById(R.id.detail_item_date_text) - private val titleView: TextView = itemView.findViewById(R.id.detail_item_title_text) - private val messageView: TextView = itemView.findViewById(R.id.detail_item_message_text) - private val newDotImageView: View = itemView.findViewById(R.id.detail_item_new_dot) - private val tagsView: TextView = itemView.findViewById(R.id.detail_item_tags_text) - private val menuButton: ImageButton = itemView.findViewById(R.id.detail_item_menu_button) - private val actionsWrapperView: ConstraintLayout = itemView.findViewById(R.id.detail_item_actions_wrapper) - private val actionsFlow: Flow = itemView.findViewById(R.id.detail_item_actions_flow) - - fun bind(notification: Notification) { - this.notification = notification - - val context = itemView.context - val unmatchedTags = unmatchedTags(splitTags(notification.tags)) - val message = formatMessage(notification) - - dateView.text = formatDateShort(notification.timestamp) - messageView.autoLinkMask = Linkify.WEB_URLS or Linkify.EMAIL_ADDRESSES or Linkify.PHONE_NUMBERS - messageView.text = message - messageView.movementMethod = BetterLinkMovementMethod.getInstance() - messageView.setOnClickListener { - // Click & Long-click listeners on the text as well, because "autoLink=web" makes them - // clickable, and so we cannot rely on the underlying card to perform the action. - // It's weird because "layout" is the ripple-able, but the card is clickable. - // See https://github.com/binwiederhier/ntfy/issues/226 - layout.ripple(lifecycleScope) - onClick(notification) - } - messageView.setOnLongClickListener { - onLongClick(notification); true - } - newDotImageView.visibility = if (notification.notificationId == 0) View.GONE else View.VISIBLE - cardView.setOnClickListener { onClick(notification) } - cardView.setOnLongClickListener { onLongClick(notification); true } - if (notification.title != "") { - titleView.visibility = View.VISIBLE - titleView.text = formatTitle(notification) - } else { - titleView.visibility = View.GONE - } - if (unmatchedTags.isNotEmpty()) { - tagsView.visibility = View.VISIBLE - tagsView.text = context.getString(R.string.detail_item_tags, unmatchedTags.joinToString(", ")) - } else { - tagsView.visibility = View.GONE - } - if (selected.contains(notification.id)) { - cardView.setCardBackgroundColor(Colors.cardSelectedBackgroundColor(context)) - } else { - cardView.setCardBackgroundColor(Colors.cardBackgroundColor(context)) - } - renderPriority(context, notification) - resetCardButtons() - maybeRenderMenu(context, notification) - } - - private fun renderPriority(context: Context, notification: Notification) { - when (notification.priority) { - PRIORITY_MIN -> { - priorityImageView.visibility = View.VISIBLE - priorityImageView.setImageDrawable(ContextCompat.getDrawable(context, R.drawable.ic_priority_1_24dp)) - } - PRIORITY_LOW -> { - priorityImageView.visibility = View.VISIBLE - priorityImageView.setImageDrawable(ContextCompat.getDrawable(context, R.drawable.ic_priority_2_24dp)) - } - PRIORITY_DEFAULT -> { - priorityImageView.visibility = View.GONE - } - PRIORITY_HIGH -> { - priorityImageView.visibility = View.VISIBLE - priorityImageView.setImageDrawable(ContextCompat.getDrawable(context, R.drawable.ic_priority_4_24dp)) - } - PRIORITY_MAX -> { - priorityImageView.visibility = View.VISIBLE - priorityImageView.setImageDrawable(ContextCompat.getDrawable(context, R.drawable.ic_priority_5_24dp)) - } - } - } - - private fun maybeRenderMenu(context: Context, notification: Notification) { - val menuButtonPopupMenu = maybeCreateMenuPopup(context, menuButton, notification) // Heavy lifting not during on-click - if (menuButtonPopupMenu != null) { - menuButton.setOnClickListener { menuButtonPopupMenu.show() } - menuButton.visibility = View.VISIBLE - } else { - menuButton.visibility = View.GONE - } - } - - private fun resetCardButtons() { - // clear any previously created dynamic buttons - actionsFlow.allViews.forEach { actionsFlow.removeView(it) } - actionsWrapperView.removeAllViews() - actionsWrapperView.addView(actionsFlow) - } - - private fun addButtonToCard(button: View) { - actionsWrapperView.addView(button) - actionsFlow.addView(button) - } - - private fun createCardButton(context: Context, label: String, onClick: () -> Boolean): View { - // See https://stackoverflow.com/a/41139179/1440785 - val button = LayoutInflater.from(context).inflate(R.layout.button_action, null) as MaterialButton - button.id = View.generateViewId() - button.text = label - button.setOnClickListener { onClick() } - return button - } - - private fun maybeCreateMenuPopup(context: Context, anchor: View?, notification: Notification): PopupMenu? { - val popup = PopupMenu(context, anchor) - popup.menuInflater.inflate(R.menu.menu_detail_attachment, popup.menu) - val downloadItem = popup.menu.findItem(R.id.detail_item_menu_download) - val cancelItem = popup.menu.findItem(R.id.detail_item_menu_cancel) - val openItem = popup.menu.findItem(R.id.detail_item_menu_open) - val deleteItem = popup.menu.findItem(R.id.detail_item_menu_delete) - val saveFileItem = popup.menu.findItem(R.id.detail_item_menu_save_file) - val copyUrlItem = popup.menu.findItem(R.id.detail_item_menu_copy_url) - val copyContentsItem = popup.menu.findItem(R.id.detail_item_menu_copy_contents) - - copyContentsItem.setOnMenuItemClickListener { - copyToClipboard(context, "notification", decodeMessage(notification)); true - } - - openItem.isVisible = false - downloadItem.isVisible = false - deleteItem.isVisible = false - saveFileItem.isVisible = false - copyUrlItem.isVisible = false - cancelItem.isVisible = false - copyContentsItem.isVisible = true - - return popup - } - } - - object TopicDiffCallback : DiffUtil.ItemCallback() { - override fun areItemsTheSame(oldItem: Notification, newItem: Notification): Boolean { - return oldItem.id == newItem.id - } - - override fun areContentsTheSame(oldItem: Notification, newItem: Notification): Boolean { - return oldItem == newItem - } - } - - companion object { - const val TAG = "NtfyDetailAdapter" - const val REQUEST_CODE_WRITE_STORAGE_PERMISSION_FOR_DOWNLOAD = 9876 - const val IMAGE_PREVIEW_MAX_BYTES = 5 * 1024 * 1024 // Too large images crash the app with "Canvas: trying to draw too large(233280000bytes) bitmap." - } -} diff --git a/android/app/src/main/java/com/lonecloud/sup/ui/MainActivity.kt b/android/app/src/main/java/com/lonecloud/sup/ui/MainActivity.kt index ac26f7d..1a6cb3c 100644 --- a/android/app/src/main/java/com/lonecloud/sup/ui/MainActivity.kt +++ b/android/app/src/main/java/com/lonecloud/sup/ui/MainActivity.kt @@ -36,11 +36,6 @@ import androidx.core.view.isVisible import androidx.lifecycle.lifecycleScope import androidx.recyclerview.widget.RecyclerView import androidx.swiperefreshlayout.widget.SwipeRefreshLayout -import androidx.work.Constraints -import androidx.work.ExistingPeriodicWorkPolicy -import androidx.work.NetworkType -import androidx.work.PeriodicWorkRequestBuilder -import androidx.work.WorkManager import com.google.android.material.appbar.AppBarLayout import com.google.android.material.dialog.MaterialAlertDialogBuilder import com.google.android.material.floatingactionbutton.FloatingActionButton @@ -61,7 +56,6 @@ import com.lonecloud.sup.util.maybeSplitTopicUrl import com.lonecloud.sup.util.randomSubscriptionId import com.lonecloud.sup.util.shortUrl import com.lonecloud.sup.util.topicShortUrl -import com.lonecloud.sup.work.DeleteWorker import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.delay import kotlinx.coroutines.launch @@ -84,12 +78,10 @@ class MainActivity : AppCompatActivity(), AddFragment.SubscribeListener { // UI elements private lateinit var menu: Menu private lateinit var mainList: RecyclerView - private lateinit var mainListContainer: SwipeRefreshLayout private lateinit var adapter: MainAdapter private lateinit var fab: FloatingActionButton // Other stuff - private var workManager: WorkManager? = null // Context-dependent private var dispatcher: NotificationDispatcher? = null // Context-dependent private var appBaseUrl: String? = null // Context-dependent @@ -131,7 +123,6 @@ class MainActivity : AppCompatActivity(), AddFragment.SubscribeListener { Log.d(TAG, "Create $this") // Dependencies that depend on Context - workManager = WorkManager.getInstance(this) dispatcher = NotificationDispatcher(this, repository) appBaseUrl = getString(R.string.app_base_url) @@ -169,11 +160,6 @@ class MainActivity : AppCompatActivity(), AddFragment.SubscribeListener { insets } - // Swipe to refresh - mainListContainer = findViewById(R.id.main_subscriptions_list_container) - mainListContainer.setOnRefreshListener { refreshAllSubscriptions() } - mainListContainer.setColorSchemeColors(Colors.swipeToRefreshColor(this)) - // Update main list based on viewModel (& its datasource/livedata) val noEntries: View = findViewById(R.id.main_no_subscriptions) val onSubscriptionClick = { s: Subscription -> onSubscriptionItemClick(s) } @@ -204,10 +190,10 @@ class MainActivity : AppCompatActivity(), AddFragment.SubscribeListener { // Update main list adapter.submitList(subscriptions as MutableList) if (it.isEmpty()) { - mainListContainer.visibility = View.GONE + mainList.visibility = View.GONE noEntries.visibility = View.VISIBLE } else { - mainListContainer.visibility = View.VISIBLE + mainList.visibility = View.VISIBLE noEntries.visibility = View.GONE } @@ -291,7 +277,6 @@ class MainActivity : AppCompatActivity(), AddFragment.SubscribeListener { // Background things schedulePeriodicServiceRestartWorker() - schedulePeriodicDeleteWorker() // Permissions maybeRequestNotificationPermission() @@ -326,24 +311,6 @@ class MainActivity : AppCompatActivity(), AddFragment.SubscribeListener { Log.d(TAG, "Battery: ignoring optimizations = $ignoringOptimizations (we want this to be true); remind time reached = $batteryRemindTimeReached; banner = $showBanner") } - private fun schedulePeriodicDeleteWorker() { - val workerVersion = repository.getDeleteWorkerVersion() - val workPolicy = if (workerVersion == DeleteWorker.VERSION) { - Log.d(TAG, "Delete worker version matches: choosing KEEP as existing work policy") - ExistingPeriodicWorkPolicy.KEEP - } else { - Log.d(TAG, "Delete worker version DOES NOT MATCH: choosing REPLACE as existing work policy") - repository.setDeleteWorkerVersion(DeleteWorker.VERSION) - ExistingPeriodicWorkPolicy.REPLACE - } - val work = PeriodicWorkRequestBuilder(DELETE_WORKER_INTERVAL_MINUTES, TimeUnit.MINUTES) - .addTag(DeleteWorker.TAG) - .addTag(DeleteWorker.WORK_NAME_PERIODIC_ALL) - .build() - Log.d(TAG, "Delete worker: Scheduling period work every $DELETE_WORKER_INTERVAL_MINUTES minutes") - workManager!!.enqueueUniquePeriodicWork(DeleteWorker.WORK_NAME_PERIODIC_ALL, workPolicy, work) - } - private fun schedulePeriodicServiceRestartWorker() { // Service restart worker not needed for Signal-based implementation Log.d(TAG, "ServiceStartWorker: Not scheduling (using Signal push notifications)") @@ -563,10 +530,8 @@ class MainActivity : AppCompatActivity(), AddFragment.SubscribeListener { private fun onSubscriptionItemClick(subscription: Subscription) { if (actionMode != null) { handleActionModeClick(subscription) - } else if (subscription.upAppId != null) { // UnifiedPush + } else if (subscription.upAppId != null) { startDetailSettingsView(subscription) - } else { - startDetailView(subscription) } } @@ -576,56 +541,7 @@ class MainActivity : AppCompatActivity(), AddFragment.SubscribeListener { } } - private fun refreshAllSubscriptions() { - lifecycleScope.launch(Dispatchers.IO) { - Log.d(TAG, "Polling for new notifications") - var errors = 0 - var errorMessage = "" // First error - var newNotificationsCount = 0 - repository.getSubscriptions().forEach { subscription -> - Log.d(TAG, "subscription: $subscription") - try { - val notifications = api.poll(subscription.id, subscription.baseUrl, subscription.topic, null) - val newNotifications = repository.onlyNewNotifications(subscription.id, notifications) - newNotifications.forEach { notification -> - newNotificationsCount++ - val notificationWithId = notification.copy(notificationId = Random.nextInt()) - if (repository.addNotification(notificationWithId)) { - dispatcher?.dispatch(subscription, notificationWithId) - } - } - } catch (e: Exception) { - val topic = displayName(appBaseUrl, subscription) - if (errorMessage == "") errorMessage = "$topic: ${e.message}" - errors++ - } - } - val toastMessage = if (errors > 0) { - getString(R.string.refresh_message_error, errors, errorMessage) - } else if (newNotificationsCount == 0) { - getString(R.string.refresh_message_no_results) - } else { - getString(R.string.refresh_message_result, newNotificationsCount) - } - runOnUiThread { - Toast.makeText(this@MainActivity, toastMessage, Toast.LENGTH_LONG).show() - mainListContainer.isRefreshing = false - } - Log.d(TAG, "Finished polling for new notifications") - } - } - private fun startDetailView(subscription: Subscription) { - Log.d(TAG, "Entering detail view for subscription $subscription") - - val intent = Intent(this, DetailActivity::class.java) - intent.putExtra(EXTRA_SUBSCRIPTION_ID, subscription.id) - intent.putExtra(EXTRA_SUBSCRIPTION_BASE_URL, subscription.baseUrl) - intent.putExtra(EXTRA_SUBSCRIPTION_TOPIC, subscription.topic) - intent.putExtra(EXTRA_SUBSCRIPTION_DISPLAY_NAME, displayName(appBaseUrl, subscription)) - intent.putExtra(EXTRA_SUBSCRIPTION_MUTED_UNTIL, subscription.mutedUntil) - startActivity(intent) - } private fun startDetailSettingsView(subscription: Subscription) { Log.d(TAG, "Opening subscription settings for ${topicShortUrl(subscription.baseUrl, subscription.topic)}") @@ -649,7 +565,7 @@ class MainActivity : AppCompatActivity(), AddFragment.SubscribeListener { } private fun onMultiDeleteClick() { - Log.d(DetailActivity.TAG, "Showing multi-delete dialog for selected items") + Log.d(TAG, "Showing multi-delete dialog for selected items") val dialog = MaterialAlertDialogBuilder(this) .setMessage(R.string.main_action_mode_delete_dialog_message) @@ -732,7 +648,6 @@ class MainActivity : AppCompatActivity(), AddFragment.SubscribeListener { // Thanks to varunon9 (https://gist.github.com/varunon9/f2beec0a743c96708eb0ef971a9ff9cd) for this! const val POLL_WORKER_INTERVAL_MINUTES = 60L - const val DELETE_WORKER_INTERVAL_MINUTES = 8 * 60L const val SERVICE_START_WORKER_INTERVAL_MINUTES = 3 * 60L } } diff --git a/android/app/src/main/java/com/lonecloud/sup/util/MarkwonFactory.kt b/android/app/src/main/java/com/lonecloud/sup/util/MarkwonFactory.kt deleted file mode 100644 index 3929f52..0000000 --- a/android/app/src/main/java/com/lonecloud/sup/util/MarkwonFactory.kt +++ /dev/null @@ -1,100 +0,0 @@ -package com.lonecloud.sup.util - -import android.content.Context -import android.graphics.Typeface -import android.text.style.* -import android.text.util.Linkify -import com.lonecloud.sup.ui.Colors -import io.noties.markwon.* -import io.noties.markwon.core.CorePlugin -import io.noties.markwon.core.CoreProps -import io.noties.markwon.core.MarkwonTheme -import io.noties.markwon.ext.strikethrough.StrikethroughPlugin -import io.noties.markwon.image.ImagesPlugin -import io.noties.markwon.linkify.LinkifyPlugin -import io.noties.markwon.movement.MovementMethodPlugin -import me.saket.bettermovementmethod.BetterLinkMovementMethod -import org.commonmark.ext.gfm.tables.TableCell -import org.commonmark.ext.gfm.tables.TablesExtension -import org.commonmark.node.* -import org.commonmark.parser.Parser - -internal object MarkwonFactory { - fun createForMessage(context: Context): Markwon { - val headingSizes = floatArrayOf(1.7f, 1.5f, 1.2f, 1f, .8f, .7f) - val bulletGapWidth = (8 * context.resources.displayMetrics.density + 0.5f).toInt() - - return Markwon.builder(context) - .usePlugin(CorePlugin.create()) - .usePlugin(SoftBreakAddsNewLinePlugin.create()) - .usePlugin(MovementMethodPlugin.create(BetterLinkMovementMethod.getInstance())) - .usePlugin(ImagesPlugin.create()) - .usePlugin(LinkifyPlugin.create(Linkify.WEB_URLS)) - .usePlugin(StrikethroughPlugin.create()) - .usePlugin(object : AbstractMarkwonPlugin() { - override fun configureTheme(builder: MarkwonTheme.Builder) { - builder - .linkColor(Colors.linkColor(context)) - .isLinkUnderlined(true) - } - - override fun configureConfiguration(builder: MarkwonConfiguration.Builder) { - builder - .linkResolver(LinkResolverDef()) - } - - override fun configureSpansFactory(builder: MarkwonSpansFactory.Builder) { - builder - .setFactory(Heading::class.java) { _, props: RenderProps? -> - arrayOf( - RelativeSizeSpan(headingSizes[CoreProps.HEADING_LEVEL.require(props!!) - 1]), - StyleSpan(Typeface.BOLD) - ) - } - .setFactory(Emphasis::class.java) { _, _ -> StyleSpan(Typeface.ITALIC) } - .setFactory(StrongEmphasis::class.java) { _, _ -> StyleSpan(Typeface.BOLD) } - .setFactory(ListItem::class.java) { _, _ -> BulletSpan(bulletGapWidth) } - } - - }) - .build() - } - - fun createForNotification(context: Context): Markwon { - val headingSizes = floatArrayOf(2f, 1.5f, 1.17f, 1f, .83f, .67f) - val bulletGapWidth = (8 * context.resources.displayMetrics.density + 0.5f).toInt() - - return Markwon.builder(context) - .usePlugin(CorePlugin.create()) - .usePlugin(SoftBreakAddsNewLinePlugin.create()) - .usePlugin(ImagesPlugin.create()) - .usePlugin(StrikethroughPlugin.create()) - .usePlugin(object : AbstractMarkwonPlugin() { - override fun configureSpansFactory(builder: MarkwonSpansFactory.Builder) { - builder - .setFactory(Heading::class.java) { _, props: RenderProps? -> - arrayOf( - RelativeSizeSpan(headingSizes[CoreProps.HEADING_LEVEL.require(props!!) - 1]), - StyleSpan(Typeface.BOLD) - ) - } - .setFactory(Emphasis::class.java) { _, _ -> StyleSpan(Typeface.ITALIC) } - .setFactory(StrongEmphasis::class.java) { _, _ -> StyleSpan(Typeface.BOLD) } - .setFactory(ListItem::class.java) { _, _ -> BulletSpan(bulletGapWidth) } - .setFactory(Link::class.java) { _, _ -> null } - } - - override fun configureParser(builder: Parser.Builder) { - builder.extensions(setOf(TablesExtension.create())) - } - - override fun configureVisitor(builder: MarkwonVisitor.Builder) { - builder.on(TableCell::class.java) { visitor: MarkwonVisitor, node: TableCell? -> - visitor.visitChildren(node!!) - visitor.builder().append(' ') - } - } - }) - .build() - } -} diff --git a/android/app/src/main/java/com/lonecloud/sup/work/DeleteWorker.kt b/android/app/src/main/java/com/lonecloud/sup/work/DeleteWorker.kt deleted file mode 100644 index fe6d18d..0000000 --- a/android/app/src/main/java/com/lonecloud/sup/work/DeleteWorker.kt +++ /dev/null @@ -1,76 +0,0 @@ -package com.lonecloud.sup.work - -import android.content.Context -import androidx.core.content.FileProvider -import androidx.work.CoroutineWorker -import androidx.work.WorkerParameters -import com.lonecloud.sup.BuildConfig -import com.lonecloud.sup.db.Repository -import com.lonecloud.sup.util.Log -import com.lonecloud.sup.util.maybeFileStat -import com.lonecloud.sup.util.topicShortUrl -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.withContext -import java.io.File -import androidx.core.net.toUri - -/** - * Deletes notifications marked for deletion and attachments for deleted notifications. - */ -class DeleteWorker(ctx: Context, params: WorkerParameters) : CoroutineWorker(ctx, params) { - // IMPORTANT: - // Every time the worker is changed, the periodic work has to be REPLACEd. - // This is facilitated in the MainActivity using the VERSION below. - - init { - Log.init(ctx) // Init in all entrypoints - } - - override suspend fun doWork(): Result { - return withContext(Dispatchers.IO) { - try { - deleteExpiredNotifications() - } catch (e: Exception) { - Log.w(TAG, "Failed to delete expired notifications", e) - } - return@withContext Result.success() - } - } - - private suspend fun deleteExpiredNotifications() { - Log.d(TAG, "Deleting expired notifications") - val repository = Repository.getInstance(applicationContext) - val subscriptions = repository.getSubscriptions() - subscriptions.forEach { subscription -> - val logId = topicShortUrl(subscription.baseUrl, subscription.topic) - val deleteAfterSeconds = if (subscription.autoDelete == Repository.AUTO_DELETE_USE_GLOBAL) { - repository.getAutoDeleteSeconds() - } else { - subscription.autoDelete - } - if (deleteAfterSeconds == Repository.AUTO_DELETE_NEVER) { - Log.d(TAG, "[$logId] Not deleting any notifications; global setting set to NEVER") - return@forEach - } - - // Mark as deleted - val markDeletedOlderThanTimestamp = (System.currentTimeMillis()/1000) - deleteAfterSeconds - Log.d(TAG, "[$logId] Marking notifications older than $markDeletedOlderThanTimestamp as deleted") - repository.markAsDeletedIfOlderThan(subscription.id, markDeletedOlderThanTimestamp) - - // Hard delete - val deleteOlderThanTimestamp = (System.currentTimeMillis()/1000) - HARD_DELETE_AFTER_SECONDS - Log.d(TAG, "[$logId] Hard deleting notifications older than $markDeletedOlderThanTimestamp") - repository.removeNotificationsIfOlderThan(subscription.id, deleteOlderThanTimestamp) - } - } - - companion object { - const val VERSION = BuildConfig.VERSION_CODE - const val TAG = "NtfyDeleteWorker" - const val WORK_NAME_PERIODIC_ALL = "NtfyDeleteWorkerPeriodic" // Do not change - - private const val ONE_DAY_SECONDS = 24 * 60 * 60L - const val HARD_DELETE_AFTER_SECONDS = 4 * 30 * ONE_DAY_SECONDS // 4 months - } -} diff --git a/android/app/src/main/res/layout/activity_main.xml b/android/app/src/main/res/layout/activity_main.xml index 1bf2037..19aae8e 100644 --- a/android/app/src/main/res/layout/activity_main.xml +++ b/android/app/src/main/res/layout/activity_main.xml @@ -232,26 +232,21 @@ - - - + app:layout_constraintTop_toBottomOf="@id/main_banner_websocket_reconnect"/> =1.0.2" } }, "sha512-z5DxEA1uRnZG73UcPA4ES5NSCGnPuuouUx43OPX7KZx1yzq3N8/vx2mtXEShT5inxB3pRgnfG1hijfu7XN2YMw=="], + + "inherits": ["inherits@2.0.4", "", {}, "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ=="], + + "isarray": ["isarray@0.0.1", "", {}, "sha512-D2S+3GLxWH+uhrNEcoh/fnmYeP8E8/zHl644d/jdA0g2uyXvy3sb0qxotE+ne0LtccHknQzWwZEzhak7oJ0COQ=="], + + "readable-stream": ["readable-stream@1.1.14", "", { "dependencies": { "core-util-is": "~1.0.0", "inherits": "~2.0.1", "isarray": "0.0.1", "string_decoder": "~0.10.x" } }, "sha512-+MeVjFf4L44XUkhM1eYbD8fyEsxcV81pqMSR5gblfcLCHfZvbrqy4/qYHE+/R5HoBUT11WV5O08Cr1n3YXkWVQ=="], + + "semver": ["semver@5.3.0", "", { "bin": { "semver": "./bin/semver" } }, "sha512-mfmm3/H9+67MCVix1h+IXTpDwL6710LyHuk7+cWC9T1mE0qz4iHhh6r4hU2wrIT9iTsAAC2XQRvfblL028cpLw=="], + + "string_decoder": ["string_decoder@0.10.31", "", {}, "sha512-ev2QzSzWPYmy9GuqfIVildA4OdcGLeFZQrq5ys6RtiuF+RQQiZWr8TZNyAcuVXyQRYfEO+MsoB/1BuQVhOJuoQ=="], + + "sup-proton-bridge": ["sup-proton-bridge@workspace:proton-bridge"], + + "sup-server": ["sup-server@workspace:server"], + "typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="], "undici-types": ["undici-types@7.16.0", "", {}, "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw=="], + + "utf7": ["utf7@1.0.2", "", { "dependencies": { "semver": "~5.3.0" } }, "sha512-qQrPtYLLLl12NF4DrM9CvfkxkYI97xOb5dsnGZHE3teFr0tWiEZ9UdgMPczv24vl708cYMpe6mGXGHrotIp3Bw=="], } } diff --git a/docker-compose.yml b/docker-compose.yml index 0a1087d..ac1c69b 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -2,16 +2,40 @@ version: '3.8' services: sup-server: - image: ghcr.io/eggy/sup:latest + build: ./server ports: - '8080:8080' environment: - PORT=8080 - API_KEY=${API_KEY:-} - - SIGNAL_CLI_VERBOSE=${SIGNAL_CLI_VERBOSE:-false} + - VERBOSE=${VERBOSE:-false} volumes: - signal-data:/root/.local/share/signal-cli restart: unless-stopped + protonmail-bridge: + image: shenxn/protonmail-bridge:latest + container_name: protonmail-bridge + profiles: ['protonmail'] + volumes: + - proton-bridge-data:/root + restart: unless-stopped + + proton-bridge: + build: ./proton-bridge + container_name: sup-proton-bridge + profiles: ['protonmail'] + depends_on: + - sup-server + - protonmail-bridge + environment: + - BRIDGE_IMAP_USERNAME=${BRIDGE_IMAP_USERNAME} + - BRIDGE_IMAP_PASSWORD=${BRIDGE_IMAP_PASSWORD} + - SUP_API_KEY=${API_KEY} + - SUP_TOPIC=${SUP_TOPIC:-Proton Mail} + - VERBOSE=${VERBOSE:-false} + restart: unless-stopped + volumes: signal-data: + proton-bridge-data: diff --git a/package.json b/package.json index 38a9af3..8b3708f 100644 --- a/package.json +++ b/package.json @@ -1,10 +1,9 @@ { - "name": "sup", + "name": "sup-monorepo", "version": "0.1.0", "description": "Privacy-preserving push notifications using Signal as transport", - "module": "server/index.ts", - "type": "module", "private": true, + "type": "module", "author": { "name": "lone-cloud", "email": "lonecloud604@proton.me" @@ -14,25 +13,21 @@ "type": "git", "url": "https://github.com/lone-cloud/sup" }, - "dependencies": { - "chalk": "^5.6.2" + "workspaces": [ + "server", + "proton-bridge" + ], + "scripts": { + "check": "tsc --noEmit && biome check .", + "fix": "biome check --write .", + "android:build": "bun run scripts/test-android-build.ts", + "android:release": "bun run scripts/release-android.ts", + "android:deps": "bun run scripts/check-android-deps.ts", + "android:lockfile": "bun run scripts/update-android-lockfile.ts" }, "devDependencies": { "@biomejs/biome": "^2.3.11", "@types/bun": "^1.3.6", "typescript": "^5.9.3" - }, - "scripts": { - "dev": "bun --watch server/index.ts", - "start": "bun run server/index.ts", - "build": "bun build --compile server/index.ts --outfile sup-server", - "check": "biome check . && tsc --noEmit", - "fix": "biome check --write . && tsc --noEmit", - "postinstall": "bun run scripts/install-signal-cli.ts", - "release:docker": "git tag v$(bun -p \"require('./package.json').version\") && git push --tags", - "android:build": "bun run scripts/test-android-build.ts", - "android:release": "bun run scripts/release-android.ts", - "android:deps": "bun run scripts/check-android-deps.ts", - "android:lockfile": "bun run scripts/update-android-lockfile.ts" } } diff --git a/proton-bridge/.env.example b/proton-bridge/.env.example new file mode 100644 index 0000000..a2b1b43 --- /dev/null +++ b/proton-bridge/.env.example @@ -0,0 +1,10 @@ +# ProtonMail Bridge Configuration +PROTON_BRIDGE_HOST=localhost +PROTON_BRIDGE_PORT=1143 +PROTON_EMAIL=your-email@protonmail.com +PROTON_PASSWORD=bridge-generated-password + +# SUP Server Configuration +SUP_SERVER_URL=http://localhost:8080 +SUP_API_KEY=your-api-key +SUP_TOPIC=Proton Mail diff --git a/proton-bridge/.gitignore b/proton-bridge/.gitignore new file mode 100644 index 0000000..3931b2e --- /dev/null +++ b/proton-bridge/.gitignore @@ -0,0 +1,4 @@ +node_modules/ +.env +*.log +dist/ diff --git a/proton-bridge/Dockerfile b/proton-bridge/Dockerfile new file mode 100644 index 0000000..b9ebb17 --- /dev/null +++ b/proton-bridge/Dockerfile @@ -0,0 +1,16 @@ +FROM oven/bun:1.1.42-alpine AS builder + +WORKDIR /app + +COPY package.json bun.lock* ./ +RUN bun install --frozen-lockfile + +COPY . . + +RUN bun build --compile src/index.ts --outfile sup-proton-bridge + +FROM alpine:3.21 + +COPY --from=builder /app/sup-proton-bridge /usr/local/bin/sup-proton-bridge + +CMD ["sup-proton-bridge"] diff --git a/proton-bridge/README.md b/proton-bridge/README.md new file mode 100644 index 0000000..cefe26f --- /dev/null +++ b/proton-bridge/README.md @@ -0,0 +1,82 @@ +# SUP ProtonMail Bridge + +IMAP bridge that monitors a ProtonMail account via Proton Bridge and sends notifications to a SUP server. + +## Architecture + +``` +ProtonMail (E2EE) → Proton Bridge (IMAP) → This Bridge → SUP Server → Signal → Phone +``` + +## Prerequisites + +1. **Proton Bridge** installed and running locally + - Download from: https://proton.me/mail/bridge + - Set up your ProtonMail account + - Note the generated IMAP password (not your ProtonMail password!) + +2. **SUP Server** running + - See main README for SUP server setup + +## Configuration + +1. Copy `.env.example` to `.env`: + ```bash + cp .env.example .env + ``` + +2. Configure your settings: + + ```env + PROTON_EMAIL=your-email@protonmail.com + PROTON_PASSWORD=bridge-generated-password # From Proton Bridge settings + SUP_SERVER_URL=http://localhost:8080 + SUP_API_KEY=your-api-key # Optional + SUP_TOPIC=protonmail + ``` + +## Running Locally + +```bash +# Install dependencies +bun install + +# Run in development mode (auto-reload) +bun dev + +# Run in production mode +bun start +``` + +## Running with Docker + +```bash +# Build +docker build -t sup-proton-bridge . + +# Run +docker run -d \ + --name sup-proton-bridge \ + --env-file .env \ + --network host \ + sup-proton-bridge +``` + +## Running with Docker Compose + +See the main `docker-compose.yml` in the repo root. + +## How It Works + +1. Connects to Proton Bridge via IMAP (localhost:1143) +2. Opens INBOX and enters IDLE mode +3. When new mail arrives, fetches sender and subject +4. Sends notification to SUP server at `/notify/protonmail` +5. SUP delivers notification via Signal to your phone + +## Security Notes + +- Proton Bridge runs locally and handles E2EE decryption +- This bridge only accesses the already-decrypted IMAP interface +- No credentials are sent to SUP server +- Only email metadata (sender, subject) is transmitted diff --git a/proton-bridge/package.json b/proton-bridge/package.json new file mode 100644 index 0000000..6419bbf --- /dev/null +++ b/proton-bridge/package.json @@ -0,0 +1,18 @@ +{ + "name": "sup-proton-bridge", + "version": "0.1.0", + "description": "ProtonMail IMAP bridge for SUP notifications via Signal", + "type": "module", + "scripts": { + "dev": "bun --watch src/index.ts", + "start": "bun src/index.ts", + "build": "bun build --compile src/index.ts --outfile sup-proton-bridge" + }, + "dependencies": { + "chalk": "^5.6.2", + "imap": "^0.8.19" + }, + "devDependencies": { + "@types/imap": "^0.8.43" + } +} diff --git a/proton-bridge/src/index.ts b/proton-bridge/src/index.ts new file mode 100644 index 0000000..a3c5181 --- /dev/null +++ b/proton-bridge/src/index.ts @@ -0,0 +1,130 @@ +import Imap from 'imap'; +import chalk from 'chalk'; + +const PROTON_BRIDGE_HOST = process.env.PROTON_BRIDGE_HOST || 'protonmail-bridge'; +const PROTON_BRIDGE_PORT = Number.parseInt(process.env.PROTON_BRIDGE_PORT || '143', 10); +const BRIDGE_IMAP_USERNAME = process.env.BRIDGE_IMAP_USERNAME; +const BRIDGE_IMAP_PASSWORD = process.env.BRIDGE_IMAP_PASSWORD; // Generated by Proton Bridge + +const SUP_SERVER_URL = process.env.SUP_SERVER_URL || 'http://sup-server:8080'; +const SUP_API_KEY = process.env.SUP_API_KEY; +const SUP_TOPIC = process.env.SUP_TOPIC || 'Proton Mail'; +const VERBOSE = process.env.VERBOSE === 'true'; + +const log = (...args: unknown[]) => VERBOSE && console.log(...args); + +if (!BRIDGE_IMAP_USERNAME || !BRIDGE_IMAP_PASSWORD) { + console.error(chalk.red('Missing required env vars: BRIDGE_IMAP_USERNAME and BRIDGE_IMAP_PASSWORD')); + console.error( + chalk.yellow('Run: docker run --rm -it -v proton-bridge-data:/root shenxn/protonmail-bridge init'), + ); + console.error(chalk.yellow('Then use `login` and `info` commands to get IMAP credentials')); + process.exit(1); +} + +console.log(chalk.blue(`šŸ”— Connecting to Proton Bridge at ${PROTON_BRIDGE_HOST}:${PROTON_BRIDGE_PORT}`)); +console.log(chalk.blue(`šŸ“Ø Monitoring mailbox: ${BRIDGE_IMAP_USERNAME}`)); +console.log(chalk.blue(`šŸ”” Sending notifications to: ${SUP_SERVER_URL}/notify/${SUP_TOPIC}`)); + +const imap = new Imap({ + user: BRIDGE_IMAP_USERNAME, + password: BRIDGE_IMAP_PASSWORD, + host: PROTON_BRIDGE_HOST, + port: PROTON_BRIDGE_PORT, + tls: true, + tlsOptions: { rejectUnauthorized: false }, // Proton Bridge uses self-signed cert + keepalive: true, +}); + +async function sendNotification(title: string, message: string) { + try { + const headers: Record = { + 'Content-Type': 'application/json', + }; + + if (SUP_API_KEY) { + headers.Authorization = `Bearer ${SUP_API_KEY}`; + } + + const response = await fetch(`${SUP_SERVER_URL}/notify/${SUP_TOPIC}`, { + method: 'POST', + headers, + body: JSON.stringify({ title, message }), + }); + + if (!response.ok) { + new Error(`SUP server responded with ${response.status}`); + } + + console.log(chalk.green(`āœ… Notification sent: ${title}`)); + } catch (error) { + console.error(chalk.red('āŒ Failed to send notification:'), error); + } +} + +function openInbox() { + imap.openBox('INBOX', false, (err, box) => { + if (err) { + console.error(chalk.red('Failed to open inbox:'), err); + } + + log(`āœ… Connected to inbox (${box.messages.total} messages)`); + + imap.on('mail', async (numNewMsgs: number) => { + log(`šŸ“¬ ${numNewMsgs} new message(s) received`); + + const fetch = imap.seq.fetch(`${box.messages.total}:*`, { + bodies: 'HEADER.FIELDS (FROM SUBJECT)', + struct: true, + }); + + fetch.on('message', (msg) => { + msg.on('body', (stream) => { + let buffer = ''; + stream.on('data', (chunk) => { + buffer += chunk.toString('utf8'); + }); + stream.once('end', () => { + const header = Imap.parseHeader(buffer); + const from = header.from?.[0] || 'Unknown sender'; + const subject = header.subject?.[0] || 'No subject'; + + sendNotification('New ProtonMail', `From: ${from}\n${subject}`); + }); + }); + }); + }); + + imap.on('update', () => { + log('šŸ“Š Mailbox updated'); + }); + }); +} + +imap.once('ready', () => { + log('āœ… IMAP connection ready'); + openInbox(); +}); + +imap.once('error', (err: Error) => { + console.error(chalk.red('āŒ IMAP error:'), err); +}); + +imap.once('end', () => { + log('āš ļø IMAP connection ended, reconnecting...'); + setTimeout(() => imap.connect(), 5000); +}); + +imap.connect(); + +process.on('SIGTERM', () => { + log('Shutting down...'); + imap.end(); + process.exit(0); +}); + +process.on('SIGINT', () => { + log('Shutting down...'); + imap.end(); + process.exit(0); +}); diff --git a/scripts/check-android-deps.ts b/scripts/check-android-deps.ts index 738018b..bf10320 100644 --- a/scripts/check-android-deps.ts +++ b/scripts/check-android-deps.ts @@ -2,6 +2,34 @@ import { join } from 'node:path'; const BUILD_GRADLE = join(import.meta.dir, '..', 'android', 'app', 'build.gradle.kts'); +function compareVersions(a: string, b: string): number { + const parseVersion = (v: string) => { + const parts = v.split(/[.-]/).map((p) => { + const num = Number.parseInt(p, 10); + return Number.isNaN(num) ? p : num; + }); + return parts; + }; + + const aParts = parseVersion(a); + const bParts = parseVersion(b); + const maxLen = Math.max(aParts.length, bParts.length); + + for (let i = 0; i < maxLen; i++) { + const aPart = aParts[i] ?? 0; + const bPart = bParts[i] ?? 0; + + if (typeof aPart === 'number' && typeof bPart === 'number') { + if (aPart !== bPart) return aPart - bPart; + } else { + const aStr = String(aPart); + const bStr = String(bPart); + if (aStr !== bStr) return aStr < bStr ? -1 : 1; + } + } + return 0; +} + function isStableVersion(version: string) { const unstableKeywords = ['alpha', 'beta', 'rc', 'snapshot', 'dev', 'preview']; const lower = version.toLowerCase(); @@ -11,16 +39,29 @@ function isStableVersion(version: string) { async function parseCurrentVersions() { const content = await Bun.file(BUILD_GRADLE).text(); const deps: Record = {}; + const variables: Record = {}; - const regex = /implementation\("([^:]+):([^:]+):([^"]+)"\)/g; - let match: RegExpExecArray | null = regex.exec(content); - - while (match !== null) { - const [, group, artifact, version] = match; - if (group && artifact && version) { - deps[`${group}:${artifact}`] = version; + const varRegex = /val\s+(\w+)\s*=\s*"([^"]+)"/g; + let varMatch: RegExpExecArray | null = varRegex.exec(content); + while (varMatch !== null) { + const [, varName, varValue] = varMatch; + if (varName && varValue) { + variables[varName] = varValue; } - match = regex.exec(content); + varMatch = varRegex.exec(content); + } + + const depRegex = /implementation\("([^:]+):([^:]+):([^"]+)"\)/g; + let depMatch: RegExpExecArray | null = depRegex.exec(content); + + while (depMatch !== null) { + const [, group, artifact, version] = depMatch; + if (group && artifact && version) { + const varMatch = version.match(/\$(\w+)/); + const resolvedVersion = varMatch?.[1] ? (variables[varMatch[1]] ?? version) : version; + deps[`${group}:${artifact}`] = resolvedVersion; + } + depMatch = depRegex.exec(content); } return deps; @@ -28,7 +69,7 @@ async function parseCurrentVersions() { async function checkMavenVersion(group: string, artifact: string) { try { - const url = `https://search.maven.org/solrsearch/select?q=g:${encodeURIComponent(group)}+AND+a:${encodeURIComponent(artifact)}&rows=50&wt=json&core=gav`; + const url = `https://search.maven.org/solrsearch/select?q=g:${encodeURIComponent(group)}+AND+a:${encodeURIComponent(artifact)}&rows=100&wt=json&core=gav`; const response = await fetch(url); const data = (await response.json()) as { response: { docs: Array<{ v: string }> }; @@ -36,8 +77,13 @@ async function checkMavenVersion(group: string, artifact: string) { const versions = [...new Set(data.response.docs.map((doc) => doc.v))]; const stableVersions = versions.filter(isStableVersion); + const sortedVersions = stableVersions.sort(compareVersions); - return stableVersions[0] || versions[0] || null; + return ( + sortedVersions[sortedVersions.length - 1] || + versions.sort(compareVersions)[versions.length - 1] || + null + ); } catch (_error) { return null; } @@ -54,7 +100,12 @@ async function checkGoogleMavenVersion(group: string, artifact: string) { const versions = Array.from(versionMatches, (m) => m[1]).filter((v): v is string => !!v); const stableVersions = versions.filter(isStableVersion); - return stableVersions[stableVersions.length - 1] ?? versions[versions.length - 1] ?? null; + const sortedVersions = stableVersions.sort(compareVersions); + return ( + sortedVersions[sortedVersions.length - 1] ?? + versions.sort(compareVersions)[versions.length - 1] ?? + null + ); } catch (_error) { return null; } @@ -96,9 +147,11 @@ async function main() { if (latestVersion === currentVersion) { console.log(`āœ“ ${dep}: ${currentVersion} (latest)`); - } else { + } else if (compareVersions(currentVersion, latestVersion) < 0) { console.log(`⚠ ${dep}: ${currentVersion} → ${latestVersion}`); hasUpdates = true; + } else { + console.log(`ℹ ${dep}: ${currentVersion} (newer than Maven: ${latestVersion})`); } } diff --git a/Dockerfile b/server/Dockerfile similarity index 84% rename from Dockerfile rename to server/Dockerfile index a83d366..9dc964a 100644 --- a/Dockerfile +++ b/server/Dockerfile @@ -7,7 +7,7 @@ RUN bun install --frozen-lockfile COPY . . -RUN bun build --compile server/index.ts --outfile sup-server +RUN bun build --compile src/server.ts --outfile sup-server FROM alpine:3.21 diff --git a/server/package.json b/server/package.json new file mode 100644 index 0000000..b999fd4 --- /dev/null +++ b/server/package.json @@ -0,0 +1,19 @@ +{ + "name": "sup-server", + "version": "0.1.0", + "description": "Privacy-preserving push notifications using Signal as transport", + "module": "index.ts", + "type": "module", + "private": true, + "license": "AGPL-3.0-or-later", + "dependencies": { + "chalk": "^5.6.2" + }, + "scripts": { + "dev": "bun --watch src/server.ts", + "start": "bun run src/server.ts", + "build": "bun build --compile src/server.ts --outfile sup-server", + "check": "tsc --noEmit", + "postinstall": "bun run ../scripts/install-signal-cli.ts" + } +} diff --git a/server/routes/link.ts b/server/routes/link.ts index a1cced2..3401be9 100644 --- a/server/routes/link.ts +++ b/server/routes/link.ts @@ -34,9 +34,7 @@ export const handleLinkStatus = async () => { await finishLink(); await initSignal({}); linked = true; - } catch { - // Not ready yet or failed - } + } catch {} } return Response.json({ linked }); diff --git a/server/routes/notify.ts b/server/routes/notify.ts index f1dd432..d4fa3b9 100644 --- a/server/routes/notify.ts +++ b/server/routes/notify.ts @@ -5,35 +5,15 @@ interface NotificationMessage { topic: string; title?: string; message: string; - priority?: 'min' | 'low' | 'default' | 'high' | 'urgent'; - tags?: string; - click?: string; } const formatNotification = (notification: NotificationMessage) => { const parts: string[] = []; - const priorityEmoji = { - min: 'šŸ”•', - low: 'šŸ”‰', - default: 'šŸ””', - high: 'āš ļø', - urgent: '🚨', - }; - - const emoji = priorityEmoji[notification.priority || 'default']; const title = notification.title || notification.topic; - parts.push(`${emoji} **${title}**`); + parts.push(`**${title}**`); parts.push(notification.message); - if (notification.tags) { - parts.push(`\n_Tags: ${notification.tags}_`); - } - - if (notification.click) { - parts.push(`\nšŸ”— ${notification.click}`); - } - return parts.join('\n'); }; @@ -55,40 +35,27 @@ export const handleNotify = async (req: Request, url: URL) => { } const title = req.headers.get('x-title') || undefined; - const priority = (req.headers.get('x-priority') || 'default') as NotificationMessage['priority']; - const tags = req.headers.get('x-tags') || undefined; - const click = req.headers.get('x-click') || undefined; const notification: NotificationMessage = { topic, title, message: body, - priority, - tags, - click, }; - // Get or create a Signal group for this topic const topicKey = `notify-${topic}`; - const groupId = getGroupId(topicKey) ?? (await createGroup(`SUP - ${topic}`)); + const groupId = getGroupId(topicKey) ?? (await createGroup(topic)); if (!getGroupId(topicKey)) { register(topicKey, groupId, topic); } - // Format and send message const signalMessage = formatNotification(notification); await sendGroupMessage(groupId, signalMessage); - // Store notification - const priorityValue = { min: 1, low: 2, default: 3, high: 4, urgent: 5 }[priority || 'default']; addNotification({ topic, title, message: body, - priority: priorityValue, - tags: tags?.split(',').map((t) => t.trim()), - click, }); return Response.json({ success: true, topic, groupId }); diff --git a/server/routes/unifiedpush.ts b/server/routes/unifiedpush.ts index fa8bf6d..231e066 100644 --- a/server/routes/unifiedpush.ts +++ b/server/routes/unifiedpush.ts @@ -30,7 +30,7 @@ export const handleRegister = async (req: Request, url: URL) => { token?: string; }; - const groupId: string = getGroupId(endpointId) ?? (await createGroup(`SUP - ${appName}`)); + const groupId: string = getGroupId(endpointId) ?? (await createGroup(appName)); if (!getGroupId(endpointId)) { register(endpointId, groupId, appName); diff --git a/server/signal.ts b/server/signal.ts index ef1a8fd..6c7c446 100644 --- a/server/signal.ts +++ b/server/signal.ts @@ -98,17 +98,13 @@ export async function finishLink() { } export async function unlinkDevice() { - // Clear local account state account = null; currentLinkUri = null; - // Delete signal-cli account data to force fresh linking const dataPath = `${process.env.HOME}/.local/share/signal-cli/data`; try { await Bun.spawn(['rm', '-rf', dataPath], { stdout: 'pipe' }); - } catch { - // Ignore errors if directory doesn't exist - } + } catch {} } export async function createGroup(name: string, members: string[] = []) { @@ -155,7 +151,7 @@ export async function startDaemon() { stderr: 'pipe', }); - const verbose = Bun.env.SIGNAL_CLI_VERBOSE === 'true'; + const verbose = Bun.env.VERBOSE === 'true'; (async () => { for await (const chunk of proc.stderr) { @@ -164,7 +160,6 @@ export async function startDaemon() { if (!trimmed) continue; - // Ignore known harmless signal-cli bugs if (trimmed.includes('ConcurrentModificationException')) continue; if (verbose) { diff --git a/server/unifiedpush.ts b/server/unifiedpush.ts index 85be885..074cf4f 100644 --- a/server/unifiedpush.ts +++ b/server/unifiedpush.ts @@ -5,6 +5,8 @@ export interface UnifiedPushMessage { data?: Record; } +const formatUpPrefix = (endpoint: string) => `[UP:${endpoint}]`; + export const parseUnifiedPushRequest = async (req: Request) => { const url = new URL(req.url); const endpointId = url.pathname.split('/').pop() ?? ''; @@ -36,6 +38,8 @@ export const parseUnifiedPushRequest = async (req: Request) => { export const formatAsSignalMessage = (msg: UnifiedPushMessage) => { const parts: string[] = []; + parts.push(formatUpPrefix(msg.endpoint)); + if (msg.title) { parts.push(`**${msg.title}**`); } @@ -48,5 +52,5 @@ export const formatAsSignalMessage = (msg: UnifiedPushMessage) => { parts.push(JSON.stringify(msg.data, null, 2)); } - return parts.join('\n\n') || 'Empty notification'; + return parts.join('\n\n') || `${formatUpPrefix(msg.endpoint)}\nEmpty notification`; };