more mobile clean up and dep updates, new proton-bidge service for protonmail notifications,

This commit is contained in:
lone-cloud 2026-01-16 00:02:17 -08:00
parent e665128e09
commit 40364d49df
35 changed files with 779 additions and 1737 deletions

41
.github/workflows/android-ci.yml vendored Normal file
View 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

View file

@ -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
View file

@ -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
View file

@ -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
```

View file

@ -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
View 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=

View file

@ -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?
)
}

View file

@ -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)

View file

@ -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"

View file

@ -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

View file

@ -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)
}

View file

@ -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"
}
}

View file

@ -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."
}
}

View file

@ -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
}
}

View file

@ -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()
}
}

View file

@ -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
}
}

View file

@ -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"

View file

@ -5,6 +5,14 @@ plugins {
subprojects {
configurations.all {
resolutionStrategy.activateDependencyLocking()
resolutionStrategy {
activateDependencyLocking()
failOnDynamicVersions()
failOnChangingVersions()
}
}
dependencyLocking {
lockAllConfigurations()
}
}

View file

@ -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=="],
}
}

View file

@ -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:

View file

@ -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"
}
}

View 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
View file

@ -0,0 +1,4 @@
node_modules/
.env
*.log
dist/

16
proton-bridge/Dockerfile Normal file
View 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
View 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

View 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
View 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);
});

View file

@ -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})`);
}
}

View file

@ -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
View 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"
}
}

View file

@ -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 });

View file

@ -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 });

View file

@ -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);

View file

@ -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) {

View file

@ -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`;
};