mirror of
https://github.com/lone-cloud/prism
synced 2026-06-03 08:43:10 -07:00
more mobile clean up and dep updates, new proton-bidge service for protonmail notifications,
This commit is contained in:
parent
e665128e09
commit
40364d49df
35 changed files with 779 additions and 1737 deletions
41
.github/workflows/android-ci.yml
vendored
Normal file
41
.github/workflows/android-ci.yml
vendored
Normal file
|
|
@ -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
|
||||
17
.github/workflows/ci.yml
vendored
17
.github/workflows/ci.yml
vendored
|
|
@ -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
|
||||
|
||||
|
|
|
|||
11
NOTICE
11
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:
|
||||
|
||||
|
|
|
|||
133
README.md
133
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
|
||||
```
|
||||
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
106
android/app/gradle.lockfile
Normal file
106
android/app/gradle.lockfile
Normal file
|
|
@ -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=
|
||||
|
|
@ -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<String>): 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?
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<DetailViewModel> {
|
||||
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<View>(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<com.google.android.material.appbar.MaterialToolbar>(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<View>(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<TextView>(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<Notification>)
|
||||
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<View>(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<Notification>) {
|
||||
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<String, com.lonecloud.sup.db.ConnectionDetails>) {
|
||||
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"
|
||||
}
|
||||
}
|
||||
|
|
@ -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<Notification, DetailAdapter.DetailViewHolder>(TopicDiffCallback) {
|
||||
private val markwon: Markwon = MarkwonFactory.createForMessage(activity)
|
||||
val selected = mutableSetOf<String>() // 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<String>,
|
||||
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<Notification>() {
|
||||
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."
|
||||
}
|
||||
}
|
||||
|
|
@ -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<Subscription>)
|
||||
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<DeleteWorker>(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
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -232,26 +232,21 @@
|
|||
</androidx.constraintlayout.widget.ConstraintLayout>
|
||||
</com.google.android.material.card.MaterialCardView>
|
||||
|
||||
<androidx.swiperefreshlayout.widget.SwipeRefreshLayout
|
||||
android:id="@+id/main_subscriptions_list_container"
|
||||
<androidx.recyclerview.widget.RecyclerView
|
||||
android:id="@+id/main_subscriptions_list"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="0dp"
|
||||
android:visibility="visible"
|
||||
android:clickable="true"
|
||||
android:focusable="true"
|
||||
android:paddingTop="5dp"
|
||||
android:paddingBottom="5dp"
|
||||
android:clipToPadding="false"
|
||||
android:background="?android:attr/selectableItemBackground"
|
||||
app:layoutManager="LinearLayoutManager"
|
||||
app:layout_constraintStart_toStartOf="parent" app:layout_constraintEnd_toEndOf="parent"
|
||||
app:layout_constraintBottom_toBottomOf="parent"
|
||||
app:layout_constraintTop_toBottomOf="@id/main_banner_websocket_reconnect">
|
||||
<androidx.recyclerview.widget.RecyclerView
|
||||
android:id="@+id/main_subscriptions_list"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:clickable="true"
|
||||
android:focusable="true"
|
||||
android:paddingTop="5dp"
|
||||
android:paddingBottom="5dp"
|
||||
android:clipToPadding="false"
|
||||
android:background="?android:attr/selectableItemBackground"
|
||||
app:layoutManager="LinearLayoutManager"/>
|
||||
</androidx.swiperefreshlayout.widget.SwipeRefreshLayout>
|
||||
app:layout_constraintTop_toBottomOf="@id/main_banner_websocket_reconnect"/>
|
||||
|
||||
<LinearLayout
|
||||
android:orientation="vertical"
|
||||
|
|
|
|||
|
|
@ -5,6 +5,14 @@ plugins {
|
|||
|
||||
subprojects {
|
||||
configurations.all {
|
||||
resolutionStrategy.activateDependencyLocking()
|
||||
resolutionStrategy {
|
||||
activateDependencyLocking()
|
||||
failOnDynamicVersions()
|
||||
failOnChangingVersions()
|
||||
}
|
||||
}
|
||||
|
||||
dependencyLocking {
|
||||
lockAllConfigurations()
|
||||
}
|
||||
}
|
||||
|
|
|
|||
43
bun.lock
43
bun.lock
|
|
@ -4,15 +4,30 @@
|
|||
"workspaces": {
|
||||
"": {
|
||||
"name": "sup",
|
||||
"dependencies": {
|
||||
"chalk": "^5.6.2",
|
||||
},
|
||||
"devDependencies": {
|
||||
"@biomejs/biome": "^2.3.11",
|
||||
"@types/bun": "^1.3.6",
|
||||
"typescript": "^5.9.3",
|
||||
},
|
||||
},
|
||||
"proton-bridge": {
|
||||
"name": "sup-proton-bridge",
|
||||
"version": "0.1.0",
|
||||
"dependencies": {
|
||||
"chalk": "^5.6.2",
|
||||
"imap": "^0.8.19",
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/imap": "^0.8.43",
|
||||
},
|
||||
},
|
||||
"server": {
|
||||
"name": "sup-server",
|
||||
"version": "0.1.0",
|
||||
"dependencies": {
|
||||
"chalk": "^5.6.2",
|
||||
},
|
||||
},
|
||||
},
|
||||
"packages": {
|
||||
"@biomejs/biome": ["@biomejs/biome@2.3.11", "", { "optionalDependencies": { "@biomejs/cli-darwin-arm64": "2.3.11", "@biomejs/cli-darwin-x64": "2.3.11", "@biomejs/cli-linux-arm64": "2.3.11", "@biomejs/cli-linux-arm64-musl": "2.3.11", "@biomejs/cli-linux-x64": "2.3.11", "@biomejs/cli-linux-x64-musl": "2.3.11", "@biomejs/cli-win32-arm64": "2.3.11", "@biomejs/cli-win32-x64": "2.3.11" }, "bin": { "biome": "bin/biome" } }, "sha512-/zt+6qazBWguPG6+eWmiELqO+9jRsMZ/DBU3lfuU2ngtIQYzymocHhKiZRyrbra4aCOoyTg/BmY+6WH5mv9xmQ=="],
|
||||
|
|
@ -35,14 +50,36 @@
|
|||
|
||||
"@types/bun": ["@types/bun@1.3.6", "", { "dependencies": { "bun-types": "1.3.6" } }, "sha512-uWCv6FO/8LcpREhenN1d1b6fcspAB+cefwD7uti8C8VffIv0Um08TKMn98FynpTiU38+y2dUO55T11NgDt8VAA=="],
|
||||
|
||||
"@types/imap": ["@types/imap@0.8.43", "", { "dependencies": { "@types/node": "*" } }, "sha512-POPoqrDax9mxM2N4ITZYCWaFtg1ORVfzJe4S7xwSh9aHawdEb7FwWTJYiAhzIvWp7DM+6BajnzYOwZ1BUrqtow=="],
|
||||
|
||||
"@types/node": ["@types/node@25.0.7", "", { "dependencies": { "undici-types": "~7.16.0" } }, "sha512-C/er7DlIZgRJO7WtTdYovjIFzGsz0I95UlMyR9anTb4aCpBSRWe5Jc1/RvLKUfzmOxHPGjSE5+63HgLtndxU4w=="],
|
||||
|
||||
"bun-types": ["bun-types@1.3.6", "", { "dependencies": { "@types/node": "*" } }, "sha512-OlFwHcnNV99r//9v5IIOgQ9Uk37gZqrNMCcqEaExdkVq3Avwqok1bJFmvGMCkCE0FqzdY8VMOZpfpR3lwI+CsQ=="],
|
||||
|
||||
"chalk": ["chalk@5.6.2", "", {}, "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA=="],
|
||||
|
||||
"core-util-is": ["core-util-is@1.0.3", "", {}, "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ=="],
|
||||
|
||||
"imap": ["imap@0.8.19", "", { "dependencies": { "readable-stream": "1.1.x", "utf7": ">=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=="],
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
31
package.json
31
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"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
10
proton-bridge/.env.example
Normal file
10
proton-bridge/.env.example
Normal file
|
|
@ -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
|
||||
4
proton-bridge/.gitignore
vendored
Normal file
4
proton-bridge/.gitignore
vendored
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
node_modules/
|
||||
.env
|
||||
*.log
|
||||
dist/
|
||||
16
proton-bridge/Dockerfile
Normal file
16
proton-bridge/Dockerfile
Normal file
|
|
@ -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"]
|
||||
82
proton-bridge/README.md
Normal file
82
proton-bridge/README.md
Normal file
|
|
@ -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
|
||||
18
proton-bridge/package.json
Normal file
18
proton-bridge/package.json
Normal file
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
130
proton-bridge/src/index.ts
Normal file
130
proton-bridge/src/index.ts
Normal file
|
|
@ -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<string, string> = {
|
||||
'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);
|
||||
});
|
||||
|
|
@ -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<string, string> = {};
|
||||
const variables: Record<string, string> = {};
|
||||
|
||||
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})`);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
19
server/package.json
Normal file
19
server/package.json
Normal file
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
|
@ -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 });
|
||||
|
|
|
|||
|
|
@ -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 });
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -5,6 +5,8 @@ export interface UnifiedPushMessage {
|
|||
data?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
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`;
|
||||
};
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue