diff --git a/app/build.gradle b/app/build.gradle index 330d2fb5..2a3308fa 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -1,18 +1,22 @@ +plugins { + id 'com.google.devtools.ksp' +} + repositories { mavenCentral() } apply plugin: 'com.android.application' apply plugin: 'kotlin-android' -apply plugin: 'kotlin-kapt' apply plugin: 'com.google.gms.google-services' android { - compileSdkVersion 33 + namespace "io.heckel.ntfy" + compileSdkVersion 34 defaultConfig { applicationId "io.heckel.ntfy" minSdkVersion 21 - targetSdkVersion 33 + targetSdkVersion 34 versionCode 33 versionName "1.17.0" @@ -25,6 +29,10 @@ android { arguments += ["room.schemaLocation": "$projectDir/schemas".toString()] } } + + buildFeatures { + buildConfig true + } } buildTypes { @@ -56,12 +64,12 @@ android { } compileOptions { - sourceCompatibility JavaVersion.VERSION_1_8 - targetCompatibility JavaVersion.VERSION_1_8 + sourceCompatibility JavaVersion.VERSION_17 + targetCompatibility JavaVersion.VERSION_17 } kotlinOptions { - jvmTarget = JavaVersion.VERSION_1_8.toString() + jvmTarget = JavaVersion.VERSION_17.toString() freeCompilerArgs += [ '-Xjvm-default=all-compatibility' // https://stackoverflow.com/a/71234042/1440785 ] @@ -105,9 +113,10 @@ dependencies { implementation 'com.google.code.gson:gson:2.10' // Room (SQLite) - def room_version = "2.5.1" + def room_version = "2.6.1" + implementation "androidx.room:room-runtime:$room_version" + ksp "androidx.room:room-compiler:$room_version" implementation "androidx.room:room-ktx:$room_version" - kapt "androidx.room:room-compiler:$room_version" // OkHttp (HTTP library) implementation 'com.squareup.okhttp3:okhttp:4.10.0' @@ -129,7 +138,7 @@ dependencies { implementation 'androidx.legacy:legacy-support-v4:1.0.0' // Image viewer - implementation 'com.github.stfalcon-studio:StfalconImageViewer:v1.0.1' + implementation 'com.github.stfalcon-studio:StfalconImageViewer:1.0.1' // Better click handling for links implementation 'me.saket:better-link-movement-method:2.2.0' diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 0950b404..cb991fa1 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -1,10 +1,10 @@ - + + @@ -95,7 +95,13 @@ - + + + + diff --git a/app/src/main/java/io/heckel/ntfy/db/Database.kt b/app/src/main/java/io/heckel/ntfy/db/Database.kt index baa50062..d64d8321 100644 --- a/app/src/main/java/io/heckel/ntfy/db/Database.kt +++ b/app/src/main/java/io/heckel/ntfy/db/Database.kt @@ -125,7 +125,7 @@ data class Attachment( @ColumnInfo(name = "contentUri") val contentUri: String?, // After it's downloaded, the content:// location @ColumnInfo(name = "progress") val progress: Int, // Progress during download, -1 if not downloaded ) { - constructor(name: String, type: String?, size: Long?, expires: Long?, url: String) : + @Ignore constructor(name: String, type: String?, size: Long?, expires: Long?, url: String) : this(name, type, size, expires, url, null, ATTACHMENT_PROGRESS_NONE) } @@ -140,7 +140,7 @@ data class Icon( @ColumnInfo(name = "url") val url: String, // URL (mandatory, see ntfy server) @ColumnInfo(name = "contentUri") val contentUri: String?, // After it's downloaded, the content:// location ) { - constructor(url:String) : + @Ignore constructor(url:String) : this(url, null) } @@ -197,7 +197,7 @@ data class LogEntry( @ColumnInfo(name = "message") val message: String, @ColumnInfo(name = "exception") val exception: String? ) { - constructor(timestamp: Long, tag: String, level: Int, message: String, exception: String?) : + @Ignore constructor(timestamp: Long, tag: String, level: Int, message: String, exception: String?) : this(0, timestamp, tag, level, message, exception) } diff --git a/app/src/main/java/io/heckel/ntfy/db/Repository.kt b/app/src/main/java/io/heckel/ntfy/db/Repository.kt index 71b03092..15f76db7 100644 --- a/app/src/main/java/io/heckel/ntfy/db/Repository.kt +++ b/app/src/main/java/io/heckel/ntfy/db/Repository.kt @@ -342,6 +342,16 @@ class Repository(private val sharedPrefs: SharedPreferences, private val databas .apply() } + fun getWebSocketReconnectRemindTime(): Long { + return sharedPrefs.getLong(SHARED_PREFS_WEBSOCKET_RECONNECT_REMIND_TIME, WEBSOCKET_RECONNECT_REMIND_TIME_ALWAYS) + } + + fun setWebSocketReconnectRemindTime(timeMillis: Long) { + sharedPrefs.edit() + .putLong(SHARED_PREFS_WEBSOCKET_RECONNECT_REMIND_TIME, timeMillis) + .apply() + } + fun getDefaultBaseUrl(): String? { return sharedPrefs.getString(SHARED_PREFS_DEFAULT_BASE_URL, null) ?: sharedPrefs.getString(SHARED_PREFS_UNIFIED_PUSH_BASE_URL, null) // Fall back to UP URL, removed when default is set! @@ -492,6 +502,7 @@ class Repository(private val sharedPrefs: SharedPreferences, private val databas const val SHARED_PREFS_RECORD_LOGS_ENABLED = "RecordLogs" const val SHARED_PREFS_BATTERY_OPTIMIZATIONS_REMIND_TIME = "BatteryOptimizationsRemindTime" const val SHARED_PREFS_WEBSOCKET_REMIND_TIME = "JsonStreamRemindTime" // "Use WebSocket" banner (used to be JSON stream deprecation banner) + const val SHARED_PREFS_WEBSOCKET_RECONNECT_REMIND_TIME = "WebSocketReconnectRemindTime" const val SHARED_PREFS_UNIFIED_PUSH_BASE_URL = "UnifiedPushBaseURL" // Legacy key required for migration to DefaultBaseURL const val SHARED_PREFS_DEFAULT_BASE_URL = "DefaultBaseURL" const val SHARED_PREFS_LAST_TOPICS = "LastTopics" @@ -532,6 +543,9 @@ class Repository(private val sharedPrefs: SharedPreferences, private val databas const val WEBSOCKET_REMIND_TIME_ALWAYS = 1L const val WEBSOCKET_REMIND_TIME_NEVER = Long.MAX_VALUE + const val WEBSOCKET_RECONNECT_REMIND_TIME_ALWAYS = 1L + const val WEBSOCKET_RECONNECT_REMIND_TIME_NEVER = Long.MAX_VALUE + private const val TAG = "NtfyRepository" private var instance: Repository? = null diff --git a/app/src/main/java/io/heckel/ntfy/msg/ApiService.kt b/app/src/main/java/io/heckel/ntfy/msg/ApiService.kt index 2692c849..54c48ea9 100644 --- a/app/src/main/java/io/heckel/ntfy/msg/ApiService.kt +++ b/app/src/main/java/io/heckel/ntfy/msg/ApiService.kt @@ -108,7 +108,6 @@ class ApiService { fun subscribe( baseUrl: String, topics: String, - unifiedPushTopics: String, since: String?, user: User?, notify: (topic: String, Notification) -> Unit, @@ -117,7 +116,7 @@ class ApiService { val sinceVal = since ?: "all" val url = topicUrlJson(baseUrl, topics, sinceVal) Log.d(TAG, "Opening subscription connection to $url") - val request = requestBuilder(url, user, unifiedPushTopics).build() + val request = requestBuilder(url, user).build() val call = subscriberClient.newCall(request) call.enqueue(object : Callback { override fun onResponse(call: Call, response: Response) { @@ -179,16 +178,13 @@ class ApiService { const val EVENT_KEEPALIVE = "keepalive" const val EVENT_POLL_REQUEST = "poll_request" - fun requestBuilder(url: String, user: User?, unifiedPushTopics: String? = null): Request.Builder { + fun requestBuilder(url: String, user: User?): Request.Builder { val builder = Request.Builder() .url(url) .addHeader("User-Agent", USER_AGENT) if (user != null) { builder.addHeader("Authorization", Credentials.basic(user.username, user.password, UTF_8)) } - if (unifiedPushTopics != null) { - builder.addHeader("Rate-Topics", unifiedPushTopics) - } return builder } } diff --git a/app/src/main/java/io/heckel/ntfy/service/Connection.kt b/app/src/main/java/io/heckel/ntfy/service/Connection.kt index dc151e50..71a98be4 100644 --- a/app/src/main/java/io/heckel/ntfy/service/Connection.kt +++ b/app/src/main/java/io/heckel/ntfy/service/Connection.kt @@ -8,6 +8,5 @@ interface Connection { data class ConnectionId( val baseUrl: String, - val topicsToSubscriptionIds: Map, - val topicIsUnifiedPush: Map + val topicsToSubscriptionIds: Map ) diff --git a/app/src/main/java/io/heckel/ntfy/service/JsonConnection.kt b/app/src/main/java/io/heckel/ntfy/service/JsonConnection.kt index 39fa0088..8bca6883 100644 --- a/app/src/main/java/io/heckel/ntfy/service/JsonConnection.kt +++ b/app/src/main/java/io/heckel/ntfy/service/JsonConnection.kt @@ -21,10 +21,8 @@ class JsonConnection( ) : Connection { private val baseUrl = connectionId.baseUrl private val topicsToSubscriptionIds = connectionId.topicsToSubscriptionIds - private val topicIsUnifiedPush = connectionId.topicIsUnifiedPush private val subscriptionIds = topicsToSubscriptionIds.values private val topicsStr = topicsToSubscriptionIds.keys.joinToString(separator = ",") - private val unifiedPushTopicsStr = topicIsUnifiedPush.filter { entry -> entry.value }.keys.joinToString(separator = ",") private val url = topicUrl(baseUrl, topicsStr) private var since: String? = sinceId @@ -58,7 +56,7 @@ class JsonConnection( // Call /json subscribe endpoint and loop until the call fails, is canceled, // or the job or service are cancelled/stopped try { - call = api.subscribe(baseUrl, topicsStr, unifiedPushTopicsStr, since, user, notify, fail) + call = api.subscribe(baseUrl, topicsStr, since, user, notify, fail) while (!failed.get() && !call.isCanceled() && isActive && serviceActive()) { stateChangeListener(subscriptionIds, ConnectionState.CONNECTED) Log.d(TAG,"[$url] Connection is active (failed=$failed, callCanceled=${call.isCanceled()}, jobActive=$isActive, serviceStarted=${serviceActive()}") diff --git a/app/src/main/java/io/heckel/ntfy/service/SubscriberService.kt b/app/src/main/java/io/heckel/ntfy/service/SubscriberService.kt index 192cfc9f..60fbb477 100644 --- a/app/src/main/java/io/heckel/ntfy/service/SubscriberService.kt +++ b/app/src/main/java/io/heckel/ntfy/service/SubscriberService.kt @@ -4,6 +4,7 @@ import android.app.* import android.content.BroadcastReceiver import android.content.Context import android.content.Intent +import android.content.pm.ServiceInfo import android.os.Build import android.os.IBinder import android.os.PowerManager @@ -98,7 +99,11 @@ class SubscriberService : Service() { notificationManager = createNotificationChannel() serviceNotification = createNotification(title, text) - startForeground(NOTIFICATION_SERVICE_ID, serviceNotification) + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) { + startForeground(NOTIFICATION_SERVICE_ID, serviceNotification!!, ServiceInfo.FOREGROUND_SERVICE_TYPE_SPECIAL_USE) + } else { + startForeground(NOTIFICATION_SERVICE_ID, serviceNotification) + } } override fun onDestroy() { @@ -172,8 +177,8 @@ class SubscriberService : Service() { .filter { s -> s.instant } val activeConnectionIds = connections.keys().toList().toSet() val desiredConnectionIds = instantSubscriptions // Set - .groupBy { s -> ConnectionId(s.baseUrl, emptyMap(), emptyMap()) } - .map { entry -> entry.key.copy(topicsToSubscriptionIds = entry.value.associate { s -> s.topic to s.id }, topicIsUnifiedPush = entry.value.associate { s -> s.topic to (s.upConnectorToken != null) }) } + .groupBy { s -> ConnectionId(s.baseUrl, emptyMap()) } + .map { entry -> entry.key.copy(topicsToSubscriptionIds = entry.value.associate { s -> s.topic to s.id }) } .toSet() val newConnectionIds = desiredConnectionIds.subtract(activeConnectionIds) val obsoleteConnectionIds = activeConnectionIds.subtract(desiredConnectionIds) diff --git a/app/src/main/java/io/heckel/ntfy/service/WsConnection.kt b/app/src/main/java/io/heckel/ntfy/service/WsConnection.kt index 080e8482..3b34081b 100644 --- a/app/src/main/java/io/heckel/ntfy/service/WsConnection.kt +++ b/app/src/main/java/io/heckel/ntfy/service/WsConnection.kt @@ -56,10 +56,8 @@ class WsConnection( private val since = AtomicReference(sinceId) private val baseUrl = connectionId.baseUrl private val topicsToSubscriptionIds = connectionId.topicsToSubscriptionIds - private val topicIsUnifiedPush = connectionId.topicIsUnifiedPush private val subscriptionIds = topicsToSubscriptionIds.values private val topicsStr = topicsToSubscriptionIds.keys.joinToString(separator = ",") - private val unifiedPushTopicsStr = topicIsUnifiedPush.filter { entry -> entry.value }.keys.joinToString(separator = ",") private val shortUrl = topicShortUrl(baseUrl, topicsStr) init { @@ -80,7 +78,7 @@ class WsConnection( val sinceId = since.get() val sinceVal = sinceId ?: "all" val urlWithSince = topicUrlWs(baseUrl, topicsStr, sinceVal) - val request = requestBuilder(urlWithSince, user, unifiedPushTopicsStr).build() + val request = requestBuilder(urlWithSince, user).build() Log.d(TAG, "$shortUrl (gid=$globalId): Opening $urlWithSince with listener ID $nextListenerId ...") webSocket = client.newWebSocket(request, Listener(nextListenerId)) } @@ -114,7 +112,27 @@ class WsConnection( Log.d(TAG,"$shortUrl (gid=$globalId): Scheduling a restart in $seconds seconds (via alarm manager)") val reconnectTime = Calendar.getInstance() reconnectTime.add(Calendar.SECOND, seconds) - alarmManager.setExact(AlarmManager.RTC_WAKEUP, reconnectTime.timeInMillis, RECONNECT_TAG, { start() }, null) + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + if (alarmManager.canScheduleExactAlarms()) { + alarmManager.setExact( + AlarmManager.RTC_WAKEUP, + reconnectTime.timeInMillis, + RECONNECT_TAG, + { start() }, + null + ) + } else { + Log.d(TAG, "SCHEDULE_EXACT_ALARM permission denied: Failed to reschedule websocket connection") + } + } else { + alarmManager.setExact( + AlarmManager.RTC_WAKEUP, + reconnectTime.timeInMillis, + RECONNECT_TAG, + { start() }, + null + ) + } } else { Log.d(TAG, "$shortUrl (gid=$globalId): Scheduling a restart in $seconds seconds (via handler)") val handler = Handler(Looper.getMainLooper()) diff --git a/app/src/main/java/io/heckel/ntfy/ui/Colors.kt b/app/src/main/java/io/heckel/ntfy/ui/Colors.kt index ada14cbf..47e8e164 100644 --- a/app/src/main/java/io/heckel/ntfy/ui/Colors.kt +++ b/app/src/main/java/io/heckel/ntfy/ui/Colors.kt @@ -7,7 +7,7 @@ import io.heckel.ntfy.util.isDarkThemeOn class Colors { companion object { - const val refreshProgressIndicator = R.color.teal + val refreshProgressIndicator = R.color.teal fun notificationIcon(context: Context): Int { return if (isDarkThemeOn(context)) R.color.teal_light else R.color.teal diff --git a/app/src/main/java/io/heckel/ntfy/ui/MainActivity.kt b/app/src/main/java/io/heckel/ntfy/ui/MainActivity.kt index e131f32c..1c4b0f52 100644 --- a/app/src/main/java/io/heckel/ntfy/ui/MainActivity.kt +++ b/app/src/main/java/io/heckel/ntfy/ui/MainActivity.kt @@ -3,6 +3,7 @@ package io.heckel.ntfy.ui import android.Manifest import android.animation.Animator import android.animation.AnimatorListenerAdapter +import android.app.AlarmManager import android.app.AlertDialog import android.content.ActivityNotFoundException import android.content.Intent @@ -11,6 +12,7 @@ import android.net.Uri import android.os.Build import android.os.Bundle import android.provider.Settings +import android.provider.Settings.ACTION_REQUEST_SCHEDULE_EXACT_ALARM import android.text.method.LinkMovementMethod import android.view.ActionMode import android.view.Menu @@ -125,9 +127,10 @@ class MainActivity : AppCompatActivity(), ActionMode.Callback, AddFragment.Subsc Log.addScrubTerm(s.topic) } - // Update banner + WebSocket banner + // Update battery banner + WebSocket banner + websocket reconnect banner showHideBatteryBanner(subscriptions) showHideWebSocketBanner(subscriptions) + showHideWebSocketReconnectBanner(subscriptions) } } @@ -194,6 +197,34 @@ class MainActivity : AppCompatActivity(), ActionMode.Callback, AddFragment.Subsc repository.setConnectionProtocol(Repository.CONNECTION_PROTOCOL_WS) SubscriberServiceManager(this).restart() wsBanner.visibility = View.GONE + + // Maybe show WebSocketReconnectBanner + viewModel.list().observe(this) { + it?.let { subscriptions -> + showHideWebSocketReconnectBanner(subscriptions) + } + } + } + + // WebSocket Reconnect banner + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + val wsReconnectBanner = findViewById(R.id.main_banner_websocket_reconnect) + val wsReconnectText = findViewById(R.id.main_banner_websocket_reconnect_text) + val wsReconnectDismissButton = findViewById