diff --git a/app/build.gradle b/app/build.gradle index 0526456b..bab1da71 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -1,25 +1,24 @@ +import org.jetbrains.kotlin.gradle.dsl.JvmTarget + plugins { id 'com.google.devtools.ksp' } -repositories { - mavenCentral() -} apply plugin: 'com.android.application' apply plugin: 'kotlin-android' apply plugin: 'com.google.gms.google-services' android { namespace "io.heckel.ntfy" - compileSdkVersion 35 + compileSdkVersion 36 defaultConfig { applicationId "io.heckel.ntfy" - minSdkVersion 21 - targetSdkVersion 35 + minSdkVersion 26 + targetSdkVersion 36 - versionCode 48 - versionName "1.19.0" + versionCode 53 + versionName "1.20.0" testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" @@ -38,6 +37,9 @@ android { minifyEnabled false shrinkResources false debuggable false + // DEV/TEST ONLY: Uncomment this to test the release build with a debug key. + // This is required to test against the production Firebase config. + // signingConfig signingConfigs.debug } debug { minifyEnabled false @@ -67,57 +69,59 @@ android { targetCompatibility JavaVersion.VERSION_17 } - kotlinOptions { - jvmTarget = JavaVersion.VERSION_17.toString() - freeCompilerArgs += [ - '-Xjvm-default=all-compatibility' // https://stackoverflow.com/a/71234042/1440785 - ] + kotlin { + compilerOptions { + jvmTarget = JvmTarget.JVM_17 + freeCompilerArgs = [ + '-Xjvm-default=all-compatibility' // https://stackoverflow.com/a/71234042/1440785 + ] + } } } // Disables GoogleServices tasks for F-Droid variant android.applicationVariants.all { variant -> def shouldProcessGoogleServices = variant.flavorName == "play" - def googleTask = tasks.findByName("process${variant.name.capitalize()}GoogleServices") + def googleTask = tasks.named("process${variant.name.capitalize()}GoogleServices").get() googleTask.enabled = shouldProcessGoogleServices } dependencies { // AndroidX, The Basics - implementation "androidx.appcompat:appcompat:1.6.1" - implementation "androidx.core:core-ktx:1.10.1" - implementation "androidx.constraintlayout:constraintlayout:2.1.4" - implementation "androidx.activity:activity-ktx:1.7.1" - implementation "androidx.fragment:fragment-ktx:1.5.7" - implementation "androidx.work:work-runtime-ktx:2.8.1" - implementation 'androidx.preference:preference-ktx:1.2.0' + implementation "androidx.appcompat:appcompat:1.7.1" + implementation "androidx.core:core-ktx:1.17.0" + 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 serialization - implementation 'com.google.code.gson:gson:2.10' + implementation 'com.google.code.gson:gson:2.13.2' // Room (SQLite) - def room_version = "2.6.1" + def room_version = "2.8.4" implementation "androidx.room:room-runtime:$room_version" ksp "androidx.room:room-compiler:$room_version" implementation "androidx.room:room-ktx:$room_version" // OkHttp (HTTP library) - implementation 'com.squareup.okhttp3:okhttp:4.10.0' + implementation 'com.squareup.okhttp3:okhttp:5.3.2' // Firebase, sigh ... (only Google Play) - playImplementation 'com.google.firebase:firebase-messaging:23.1.2' + playImplementation 'com.google.firebase:firebase-messaging:25.0.1' // RecyclerView - implementation "androidx.recyclerview:recyclerview:1.3.0" + implementation "androidx.recyclerview:recyclerview:1.4.0" // Swipe down to refresh - implementation 'androidx.swiperefreshlayout:swiperefreshlayout:1.1.0' + 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.6.1" + implementation "androidx.lifecycle:lifecycle-livedata-ktx:2.10.0" implementation 'androidx.legacy:legacy-support-v4:1.0.0' // Image viewer diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 46b5eab3..2b99ea63 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -22,6 +22,7 @@ android:supportsRtl="true" android:theme="@style/AppTheme" android:networkSecurityConfig="@xml/network_security_config" + android:localeConfig="@xml/locales_config" android:usesCleartextTraffic="true"> @@ -39,6 +40,7 @@ 1L && System.currentTimeMillis()/1000 > mutedUntil if (expired) { - sharedPrefs.edit() - .putLong(SHARED_PREFS_MUTED_UNTIL_TIMESTAMP, 0L) - .apply() + sharedPrefs.edit { + putLong(SHARED_PREFS_MUTED_UNTIL_TIMESTAMP, 0L) + } return true } return false @@ -418,9 +432,9 @@ class Repository(private val sharedPrefs: SharedPreferences, private val databas fun addLastShareTopic(topic: String) { val topics = (getLastShareTopics().filterNot { it == topic } + topic).takeLast(LAST_TOPICS_COUNT) - sharedPrefs.edit() - .putString(SHARED_PREFS_LAST_TOPICS, topics.joinToString(separator = "\n")) - .apply() + sharedPrefs.edit { + putString(SHARED_PREFS_LAST_TOPICS, topics.joinToString(separator = "\n")) + } } private fun toSubscriptionList(list: List): List { @@ -591,6 +605,7 @@ class Repository(private val sharedPrefs: SharedPreferences, private val databas const val SHARED_PREFS_UNIFIEDPUSH_ENABLED = "UnifiedPushEnabled" const val SHARED_PREFS_INSISTENT_MAX_PRIORITY_ENABLED = "InsistentMaxPriority" const val SHARED_PREFS_RECORD_LOGS_ENABLED = "RecordLogs" + const val SHARED_PREFS_MESSAGE_BAR_ENABLED = "MessageBarEnabled" 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" 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 5a3fdf15..edfaa03f 100644 --- a/app/src/main/java/io/heckel/ntfy/msg/ApiService.kt +++ b/app/src/main/java/io/heckel/ntfy/msg/ApiService.kt @@ -2,6 +2,7 @@ package io.heckel.ntfy.msg import android.content.Context import android.os.Build +import com.google.gson.Gson import io.heckel.ntfy.BuildConfig import io.heckel.ntfy.db.Notification import io.heckel.ntfy.db.Repository @@ -15,9 +16,9 @@ import java.nio.charset.StandardCharsets.UTF_8 import java.util.concurrent.TimeUnit import kotlin.random.Random -class ApiService(private val context: Context) { +class ApiService(context: Context) { private val repository = Repository.getInstance(context) - + private val gson = Gson() private val client = OkHttpClient.Builder() .callTimeout(15, TimeUnit.SECONDS) // Total timeout for entire request .connectTimeout(15, TimeUnit.SECONDS) @@ -48,7 +49,13 @@ class ApiService(private val context: Context) { tags: List = emptyList(), delay: String = "", body: RequestBody? = null, - filename: String = "" + filename: String = "", + click: String = "", + attach: String = "", + email: String = "", + call: String = "", + markdown: Boolean = false, + onCancelAvailable: ((cancel: () -> Unit) -> Unit)? = null // Called when the HTTP request was started and cancellable (caller can cancel) ) { val url = topicUrl(baseUrl, topic) val query = mutableListOf() @@ -67,6 +74,21 @@ class ApiService(private val context: Context) { if (filename.isNotEmpty()) { query.add("filename=${URLEncoder.encode(filename, "UTF-8")}") } + if (click.isNotEmpty()) { + query.add("click=${URLEncoder.encode(click, "UTF-8")}") + } + if (attach.isNotEmpty()) { + query.add("attach=${URLEncoder.encode(attach, "UTF-8")}") + } + if (email.isNotEmpty()) { + query.add("email=${URLEncoder.encode(email, "UTF-8")}") + } + if (call.isNotEmpty()) { + query.add("call=${URLEncoder.encode(call, "UTF-8")}") + } + if (markdown) { + query.add("markdown=true") + } if (body != null) { query.add("message=${URLEncoder.encode(message.replace("\n", "\\n"), "UTF-8")}") } @@ -79,12 +101,24 @@ class ApiService(private val context: Context) { .put(body ?: message.toRequestBody()) .build() Log.d(TAG, "Publishing to $request") - publishClient.newCall(request).execute().use { response -> + val httpCall = publishClient.newCall(request) + onCancelAvailable?.invoke { httpCall.cancel() } // Notify caller that HTTP request can now be canceled + httpCall.execute().use { response -> if (response.code == 401 || response.code == 403) { throw UnauthorizedException(user) } else if (response.code == 413) { throw EntityTooLargeException() } else if (!response.isSuccessful) { + // Try to parse error response from server + val errorBody = response.body.string() + val apiError = try { + gson.fromJson(errorBody, ErrorResponse::class.java) + } catch (e: Exception) { + null + } + if (apiError?.error != null && apiError.code != null) { + throw ApiException(apiError.error, apiError.code) + } throw Exception("Unexpected response ${response.code} when publishing to $url") } Log.d(TAG, "Successfully published to $url") @@ -101,8 +135,8 @@ class ApiService(private val context: Context) { if (!response.isSuccessful) { throw Exception("Unexpected response ${response.code} when polling topic $url") } - val body = response.body?.string()?.trim() - if (body.isNullOrEmpty()) return emptyList() + val body = response.body.string().trim() + if (body.isEmpty()) return emptyList() val notifications = body.lines().mapNotNull { line -> parser.parse(line, subscriptionId = subscriptionId, notificationId = 0) // No notification when we poll } @@ -131,7 +165,7 @@ class ApiService(private val context: Context) { if (!response.isSuccessful) { throw Exception("Unexpected response ${response.code} when subscribing to topic $url") } - val source = response.body?.source() ?: throw Exception("Unexpected response for $url: body is empty") + val source = response.body.source() while (!source.exhausted()) { val line = source.readUtf8Line() ?: throw Exception("Unexpected response for $url: line is null") val notification = parser.parseWithTopic(line, notificationId = Random.nextInt(), subscriptionId = 0) // subscriptionId to be set downstream @@ -208,6 +242,13 @@ class ApiService(private val context: Context) { class UnauthorizedException(val user: User?) : Exception() class EntityTooLargeException : Exception() + class ApiException(val error: String, val code: Int) : Exception(error) + + private data class ErrorResponse( + val code: Int?, + val http: Int?, + val error: String? + ) companion object { val USER_AGENT = "ntfy/${BuildConfig.VERSION_NAME} (${BuildConfig.FLAVOR}; Android ${Build.VERSION.RELEASE}; SDK ${Build.VERSION.SDK_INT})" diff --git a/app/src/main/java/io/heckel/ntfy/msg/DownloadAttachmentWorker.kt b/app/src/main/java/io/heckel/ntfy/msg/DownloadAttachmentWorker.kt index cffd5911..f1cf32a9 100644 --- a/app/src/main/java/io/heckel/ntfy/msg/DownloadAttachmentWorker.kt +++ b/app/src/main/java/io/heckel/ntfy/msg/DownloadAttachmentWorker.kt @@ -67,7 +67,7 @@ class DownloadAttachmentWorker(private val context: Context, params: WorkerParam .build() client.newCall(request).execute().use { response -> Log.d(TAG, "Download: headers received: $response") - if (!response.isSuccessful || response.body == null) { + if (!response.isSuccessful) { throw Exception("Unexpected response: ${response.code}") } save(updateAttachmentFromResponse(response)) @@ -84,7 +84,7 @@ class DownloadAttachmentWorker(private val context: Context, params: WorkerParam val outFile = resolver.openOutputStream(uri) ?: throw Exception("Cannot open output stream") val downloadLimit = getDownloadLimit(userAction) outFile.use { fileOut -> - val fileIn = response.body!!.byteStream() + val fileIn = response.body.byteStream() val buffer = ByteArray(BUFFER_SIZE) var bytes = fileIn.read(buffer) var lastProgress = 0L diff --git a/app/src/main/java/io/heckel/ntfy/msg/DownloadIconWorker.kt b/app/src/main/java/io/heckel/ntfy/msg/DownloadIconWorker.kt index 253ee23d..c7b560dc 100644 --- a/app/src/main/java/io/heckel/ntfy/msg/DownloadIconWorker.kt +++ b/app/src/main/java/io/heckel/ntfy/msg/DownloadIconWorker.kt @@ -70,7 +70,7 @@ class DownloadIconWorker(private val context: Context, params: WorkerParameters) .build() client.newCall(request).execute().use { response -> Log.d(TAG, "Headers received: $response, Content-Length: ${response.headers["Content-Length"]}") - if (!response.isSuccessful || response.body == null) { + if (!response.isSuccessful) { throw Exception("Unexpected response: ${response.code}") } else if (shouldAbortDownload(response)) { Log.d(TAG, "Aborting download: Content-Length is larger than auto-download setting") @@ -85,7 +85,7 @@ class DownloadIconWorker(private val context: Context, params: WorkerParameters) val outFile = resolver.openOutputStream(uri) ?: throw Exception("Cannot open output stream") val downloadLimit = getDownloadLimit() outFile.use { fileOut -> - val fileIn = response.body!!.byteStream() + val fileIn = response.body.byteStream() val buffer = ByteArray(BUFFER_SIZE) var bytes = fileIn.read(buffer) while (bytes >= 0) { diff --git a/app/src/main/java/io/heckel/ntfy/msg/NotificationService.kt b/app/src/main/java/io/heckel/ntfy/msg/NotificationService.kt index 9777ef4c..6d3ee959 100644 --- a/app/src/main/java/io/heckel/ntfy/msg/NotificationService.kt +++ b/app/src/main/java/io/heckel/ntfy/msg/NotificationService.kt @@ -9,10 +9,7 @@ import android.media.AudioAttributes import android.media.AudioManager import android.media.RingtoneManager import android.net.Uri -import android.os.Build import android.os.Bundle -import android.text.SpannedString -import android.text.style.CharacterStyle import android.widget.Toast import androidx.core.app.NotificationCompat import io.heckel.ntfy.R @@ -23,6 +20,7 @@ import io.heckel.ntfy.ui.DetailActivity import io.heckel.ntfy.ui.MainActivity import io.heckel.ntfy.util.* import java.util.* +import androidx.core.net.toUri class NotificationService(val context: Context) { private val notificationManager = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager @@ -36,11 +34,7 @@ class NotificationService(val context: Context) { } fun update(subscription: Subscription, notification: Notification) { - val active = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { - notificationManager.activeNotifications.find { it.id == notification.notificationId } != null - } else { - true - } + val active = notificationManager.activeNotifications.find { it.id == notification.notificationId } != null if (active) { Log.d(TAG, "Updating notification $notification") displayInternal(subscription, notification, update = true) @@ -78,10 +72,6 @@ class NotificationService(val context: Context) { maybeDeleteNotificationGroup(groupId) } - fun channelsSupported(): Boolean { - return Build.VERSION.SDK_INT >= Build.VERSION_CODES.O - } - private fun subscriptionGroupId(subscription: Subscription): String { return SUBSCRIPTION_GROUP_PREFIX + subscription.id.toString() } @@ -195,10 +185,10 @@ class NotificationService(val context: Context) { builder.setContentIntent(detailActivityIntent(subscription)) } else { try { - val uri = Uri.parse(notification.click) + val uri = notification.click.toUri() val viewIntent = PendingIntent.getActivity(context, Random().nextInt(), Intent(Intent.ACTION_VIEW, uri), PendingIntent.FLAG_IMMUTABLE) builder.setContentIntent(viewIntent) - } catch (e: Exception) { + } catch (_: Exception) { builder.setContentIntent(detailActivityIntent(subscription)) } } @@ -218,7 +208,7 @@ class NotificationService(val context: Context) { return } if (notification.attachment?.contentUri != null) { - val contentUri = Uri.parse(notification.attachment.contentUri) + val contentUri = notification.attachment.contentUri.toUri() val intent = Intent(Intent.ACTION_VIEW, contentUri).apply { setDataAndType(contentUri, notification.attachment.type ?: "application/octet-stream") // Required for Android <= P addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) @@ -286,7 +276,7 @@ class NotificationService(val context: Context) { private fun addViewUserActionWithoutClear(builder: NotificationCompat.Builder, action: Action) { try { val url = action.url ?: return - val intent = Intent(Intent.ACTION_VIEW, Uri.parse(url)).apply { + val intent = Intent(Intent.ACTION_VIEW, url.toUri()).apply { addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK) } val pendingIntent = PendingIntent.getActivity(context, Random().nextInt(), intent, PendingIntent.FLAG_IMMUTABLE) @@ -376,61 +366,53 @@ class NotificationService(val context: Context) { } private fun maybeCreateNotificationChannel(group: String, priority: Int) { - if (channelsSupported()) { - // Note: To change a notification channel, you must delete the old one and create a new one! + // Note: To change a notification channel, you must delete the old one and create a new one! - val channelId = toChannelId(group, priority) - val pause = 300L - val channel = when (priority) { - PRIORITY_MIN -> NotificationChannel(channelId, context.getString(R.string.channel_notifications_min_name), NotificationManager.IMPORTANCE_MIN) - PRIORITY_LOW -> NotificationChannel(channelId, context.getString(R.string.channel_notifications_low_name), NotificationManager.IMPORTANCE_LOW) - PRIORITY_HIGH -> { - val channel = NotificationChannel(channelId, context.getString(R.string.channel_notifications_high_name), NotificationManager.IMPORTANCE_HIGH) - channel.enableVibration(true) - channel.vibrationPattern = longArrayOf( - pause, 100, pause, 100, pause, 100, - pause, 2000 - ) - channel - } - PRIORITY_MAX -> { - val channel = NotificationChannel(channelId, context.getString(R.string.channel_notifications_max_name), NotificationManager.IMPORTANCE_HIGH) // IMPORTANCE_MAX does not exist - channel.enableLights(true) - channel.enableVibration(true) - channel.setBypassDnd(true) - channel.vibrationPattern = longArrayOf( - pause, 100, pause, 100, pause, 100, - pause, 2000, - pause, 100, pause, 100, pause, 100, - pause, 2000, - pause, 100, pause, 100, pause, 100, - pause, 2000 - ) - channel - } - else -> NotificationChannel(channelId, context.getString(R.string.channel_notifications_default_name), NotificationManager.IMPORTANCE_DEFAULT) + val channelId = toChannelId(group, priority) + val pause = 300L + val channel = when (priority) { + PRIORITY_MIN -> NotificationChannel(channelId, context.getString(R.string.channel_notifications_min_name), NotificationManager.IMPORTANCE_MIN) + PRIORITY_LOW -> NotificationChannel(channelId, context.getString(R.string.channel_notifications_low_name), NotificationManager.IMPORTANCE_LOW) + PRIORITY_HIGH -> { + val channel = NotificationChannel(channelId, context.getString(R.string.channel_notifications_high_name), NotificationManager.IMPORTANCE_HIGH) + channel.enableVibration(true) + channel.vibrationPattern = longArrayOf( + pause, 100, pause, 100, pause, 100, + pause, 2000 + ) + channel } - channel.group = group - notificationManager.createNotificationChannel(channel) + PRIORITY_MAX -> { + val channel = NotificationChannel(channelId, context.getString(R.string.channel_notifications_max_name), NotificationManager.IMPORTANCE_HIGH) // IMPORTANCE_MAX does not exist + channel.enableLights(true) + channel.enableVibration(true) + channel.setBypassDnd(true) + channel.vibrationPattern = longArrayOf( + pause, 100, pause, 100, pause, 100, + pause, 2000, + pause, 100, pause, 100, pause, 100, + pause, 2000, + pause, 100, pause, 100, pause, 100, + pause, 2000 + ) + channel + } + else -> NotificationChannel(channelId, context.getString(R.string.channel_notifications_default_name), NotificationManager.IMPORTANCE_DEFAULT) } + channel.group = group + notificationManager.createNotificationChannel(channel) } private fun maybeDeleteNotificationChannel(group: String, priority: Int) { - if (channelsSupported()) { - notificationManager.deleteNotificationChannel(toChannelId(group, priority)) - } + notificationManager.deleteNotificationChannel(toChannelId(group, priority)) } private fun maybeCreateNotificationGroup(id: String, name: String) { - if (channelsSupported()) { - notificationManager.createNotificationChannelGroup(NotificationChannelGroup(id, name)) - } + notificationManager.createNotificationChannelGroup(NotificationChannelGroup(id, name)) } private fun maybeDeleteNotificationGroup(id: String) { - if (channelsSupported()) { - notificationManager.deleteNotificationChannelGroup(id) - } + notificationManager.deleteNotificationChannelGroup(id) } private fun toChannelId(groupId: String, priority: Int): String { @@ -467,13 +449,9 @@ class NotificationService(val context: Context) { } private fun getInsistentSound(groupId: String): Uri { - return if (channelsSupported()) { - val channelId = toChannelId(groupId, PRIORITY_MAX) - val channel = notificationManager.getNotificationChannel(channelId) - channel.sound - } else { - RingtoneManager.getDefaultUri(RingtoneManager.TYPE_NOTIFICATION) - } + val channelId = toChannelId(groupId, PRIORITY_MAX) + val channel = notificationManager.getNotificationChannel(channelId) + return channel.sound } /** @@ -496,7 +474,7 @@ class NotificationService(val context: Context) { // Immediately start the actual activity try { - val intent = Intent(Intent.ACTION_VIEW, Uri.parse(url)).apply { + val intent = Intent(Intent.ACTION_VIEW, url.toUri()).apply { addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) } startActivity(intent) 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 f30c8e96..a629f92b 100644 --- a/app/src/main/java/io/heckel/ntfy/service/SubscriberService.kt +++ b/app/src/main/java/io/heckel/ntfy/service/SubscriberService.kt @@ -28,6 +28,7 @@ import kotlinx.coroutines.GlobalScope import kotlinx.coroutines.launch import kotlinx.coroutines.sync.Mutex import java.util.concurrent.ConcurrentHashMap +import androidx.core.content.edit /** * The subscriber service manages the foreground service for instant delivery. @@ -98,10 +99,24 @@ class SubscriberService : Service() { notificationManager = createNotificationChannel() serviceNotification = createNotification(title, text) - 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) + try { + 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) + } + } catch (e: Exception) { + // On Android 12+, starting a foreground service from the background is restricted. + // ForegroundServiceStartNotAllowedException is thrown when the app is in the background. + // We stop ourselves gracefully; the service will be started when the user opens the app. + // This should not happen if the battery optimization exemption was granted by the user. + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S && e is ForegroundServiceStartNotAllowedException) { + Log.w(TAG, "Cannot start foreground service from background, stopping: ${e.message}") + stopSelf() + return + } else { + throw e + } } } @@ -120,7 +135,7 @@ class SubscriberService : Service() { Log.d(TAG, "Starting the foreground service task") isServiceStarted = true saveServiceState(this, ServiceState.STARTED) - wakeLock = (getSystemService(Context.POWER_SERVICE) as PowerManager).run { + wakeLock = (getSystemService(POWER_SERVICE) as PowerManager).run { newWakeLock(PowerManager.PARTIAL_WAKE_LOCK, WAKE_LOCK_TAG) } refreshConnections() @@ -275,18 +290,15 @@ class SubscriberService : Service() { } } - private fun createNotificationChannel(): NotificationManager? { - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { - val notificationManager = getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager - val channelName = getString(R.string.channel_subscriber_service_name) // Show's up in UI - val channel = NotificationChannel(NOTIFICATION_CHANNEL_ID, channelName, NotificationManager.IMPORTANCE_LOW).let { - it.setShowBadge(false) // Don't show long-press badge - it - } - notificationManager.createNotificationChannel(channel) - return notificationManager + private fun createNotificationChannel(): NotificationManager { + val notificationManager = getSystemService(NOTIFICATION_SERVICE) as NotificationManager + val channelName = getString(R.string.channel_subscriber_service_name) // Show's up in UI + val channel = NotificationChannel(NOTIFICATION_CHANNEL_ID, channelName, NotificationManager.IMPORTANCE_LOW).let { + it.setShowBadge(false) // Don't show long-press badge + it } - return null + notificationManager.createNotificationChannel(channel) + return notificationManager } private fun createNotification(title: String, text: String): Notification { @@ -316,8 +328,8 @@ class SubscriberService : Service() { it.setPackage(packageName) } val restartServicePendingIntent: PendingIntent = PendingIntent.getService(this, 1, restartServiceIntent, PendingIntent.FLAG_ONE_SHOT or PendingIntent.FLAG_IMMUTABLE) - applicationContext.getSystemService(Context.ALARM_SERVICE) - val alarmService: AlarmManager = applicationContext.getSystemService(Context.ALARM_SERVICE) as AlarmManager + applicationContext.getSystemService(ALARM_SERVICE) + val alarmService: AlarmManager = applicationContext.getSystemService(ALARM_SERVICE) as AlarmManager alarmService.set(AlarmManager.ELAPSED_REALTIME, SystemClock.elapsedRealtime() + 1000, restartServicePendingIntent) } @@ -364,14 +376,14 @@ class SubscriberService : Service() { private const val SHARED_PREFS_SERVICE_STATE = "ServiceState" fun saveServiceState(context: Context, state: ServiceState) { - val sharedPrefs = context.getSharedPreferences(SHARED_PREFS_ID, Context.MODE_PRIVATE) - sharedPrefs.edit() - .putString(SHARED_PREFS_SERVICE_STATE, state.name) - .apply() + val sharedPrefs = context.getSharedPreferences(SHARED_PREFS_ID, MODE_PRIVATE) + sharedPrefs.edit { + putString(SHARED_PREFS_SERVICE_STATE, state.name) + } } fun readServiceState(context: Context): ServiceState { - val sharedPrefs = context.getSharedPreferences(SHARED_PREFS_ID, Context.MODE_PRIVATE) + val sharedPrefs = context.getSharedPreferences(SHARED_PREFS_ID, MODE_PRIVATE) val value = sharedPrefs.getString(SHARED_PREFS_SERVICE_STATE, ServiceState.STOPPED.name) return ServiceState.valueOf(value!!) } diff --git a/app/src/main/java/io/heckel/ntfy/service/SubscriberServiceManager.kt b/app/src/main/java/io/heckel/ntfy/service/SubscriberServiceManager.kt index ed4dfda7..f86110c3 100644 --- a/app/src/main/java/io/heckel/ntfy/service/SubscriberServiceManager.kt +++ b/app/src/main/java/io/heckel/ntfy/service/SubscriberServiceManager.kt @@ -47,15 +47,20 @@ class SubscriberServiceManager(private val context: Context) { val app = context.applicationContext as Application val subscriptionIdsWithInstantStatus = app.repository.getSubscriptionIdsWithInstantStatus() val instantSubscriptions = subscriptionIdsWithInstantStatus.toList().filter { (_, instant) -> instant }.size - val action = if (instantSubscriptions > 0) SubscriberService.Action.START else SubscriberService.Action.STOP - val serviceState = SubscriberService.readServiceState(context) - if (serviceState == SubscriberService.ServiceState.STOPPED && action == SubscriberService.Action.STOP) { - return@withContext Result.success() - } - Log.d(TAG, "ServiceStartWorker: Starting foreground service with action $action (work ID: ${id})") - Intent(context, SubscriberService::class.java).also { - it.action = action.name - ContextCompat.startForegroundService(context, it) + if (instantSubscriptions > 0) { + // We have instant subscriptions, start the service + Log.d(TAG, "ServiceStartWorker: Starting foreground service (work ID: ${id})") + Intent(context, SubscriberService::class.java).also { + it.action = SubscriberService.Action.START.name + ContextCompat.startForegroundService(context, it) + } + } else { + // No instant subscriptions, stop the service using stopService() + // This avoids ForegroundServiceDidNotStartInTimeException, see #1520 + Log.d(TAG, "ServiceStartWorker: Stopping service (work ID: ${id})") + Intent(context, SubscriberService::class.java).also { + context.stopService(it) + } } } return Result.success() 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 795de91a..63070c94 100644 --- a/app/src/main/java/io/heckel/ntfy/service/WsConnection.kt +++ b/app/src/main/java/io/heckel/ntfy/service/WsConnection.kt @@ -2,8 +2,6 @@ package io.heckel.ntfy.service import android.app.AlarmManager import android.os.Build -import android.os.Handler -import android.os.Looper import io.heckel.ntfy.db.* import io.heckel.ntfy.msg.ApiService import io.heckel.ntfy.msg.ApiService.Companion.requestBuilder @@ -110,23 +108,11 @@ class WsConnection( return } state = State.Scheduled - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) { - Log.d(TAG,"$shortUrl (gid=$globalId): Scheduling a restart in $seconds seconds (via alarm manager)") - val reconnectTime = Calendar.getInstance() - reconnectTime.add(Calendar.SECOND, seconds) - 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 { + Log.d(TAG,"$shortUrl (gid=$globalId): Scheduling a restart in $seconds seconds (via alarm manager)") + val reconnectTime = Calendar.getInstance() + reconnectTime.add(Calendar.SECOND, seconds) + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + if (alarmManager.canScheduleExactAlarms()) { alarmManager.setExact( AlarmManager.RTC_WAKEUP, reconnectTime.timeInMillis, @@ -134,11 +120,17 @@ class WsConnection( { start() }, null ) + } else { + Log.d(TAG, "SCHEDULE_EXACT_ALARM permission denied: Failed to reschedule websocket connection") } } else { - Log.d(TAG, "$shortUrl (gid=$globalId): Scheduling a restart in $seconds seconds (via handler)") - val handler = Handler(Looper.getMainLooper()) - handler.postDelayed({ start() }, TimeUnit.SECONDS.toMillis(seconds.toLong())) + alarmManager.setExact( + AlarmManager.RTC_WAKEUP, + reconnectTime.timeInMillis, + RECONNECT_TAG, + { start() }, + null + ) } } diff --git a/app/src/main/java/io/heckel/ntfy/ui/AddFragment.kt b/app/src/main/java/io/heckel/ntfy/ui/AddFragment.kt index 3d872c85..11dc02c4 100644 --- a/app/src/main/java/io/heckel/ntfy/ui/AddFragment.kt +++ b/app/src/main/java/io/heckel/ntfy/ui/AddFragment.kt @@ -7,7 +7,6 @@ import android.os.Bundle import android.view.MenuItem import android.view.View import android.view.ViewGroup -import android.view.WindowManager import android.view.inputmethod.InputMethodManager import android.widget.* import androidx.fragment.app.DialogFragment @@ -23,6 +22,8 @@ import io.heckel.ntfy.msg.ApiService import io.heckel.ntfy.util.* import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch +import androidx.core.view.isVisible +import androidx.core.view.isGone class AddFragment : DialogFragment() { private val api by lazy { ApiService(requireContext()) } @@ -199,16 +200,16 @@ class AddFragment : DialogFragment() { subscribeTopicText.postDelayed({ subscribeTopicText.requestFocus() val imm = requireContext().getSystemService(Context.INPUT_METHOD_SERVICE) as? InputMethodManager - imm?.showSoftInput(subscribeTopicText, InputMethodManager.SHOW_FORCED) + imm?.showSoftInput(subscribeTopicText, InputMethodManager.SHOW_IMPLICIT) }, 200) } private fun onActionButtonClick() { val topic = subscribeTopicText.text.toString() val baseUrl = getBaseUrl() - if (subscribeView.visibility == View.VISIBLE) { + if (subscribeView.isVisible) { checkReadAndMaybeShowLogin(baseUrl, topic) - } else if (loginView.visibility == View.VISIBLE) { + } else if (loginView.isVisible) { loginAndMaybeDismiss(baseUrl, topic) } } @@ -349,7 +350,7 @@ class AddFragment : DialogFragment() { if (!this::actionMenuItem.isInitialized || !this::loginUsernameText.isInitialized || !this::loginPasswordText.isInitialized) { return // As per crash seen in Google Play } - if (loginUsernameText.visibility == View.GONE) { + if (loginUsernameText.isGone) { actionMenuItem.isEnabled = true } else { actionMenuItem.isEnabled = (loginUsernameText.text?.isNotEmpty() ?: false) diff --git a/app/src/main/java/io/heckel/ntfy/ui/BasePreferenceFragment.kt b/app/src/main/java/io/heckel/ntfy/ui/BasePreferenceFragment.kt index 730827f5..f948e69e 100644 --- a/app/src/main/java/io/heckel/ntfy/ui/BasePreferenceFragment.kt +++ b/app/src/main/java/io/heckel/ntfy/ui/BasePreferenceFragment.kt @@ -1,6 +1,12 @@ package io.heckel.ntfy.ui +import android.os.Bundle +import android.view.View +import android.view.WindowManager import android.widget.TextView +import androidx.core.view.ViewCompat +import androidx.core.view.WindowInsetsCompat +import androidx.core.view.updatePadding import androidx.preference.EditTextPreference import androidx.preference.ListPreference import androidx.preference.Preference @@ -10,6 +16,20 @@ import com.google.android.material.textfield.TextInputEditText import io.heckel.ntfy.R abstract class BasePreferenceFragment : PreferenceFragmentCompat() { + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + + // Apply window insets to ensure content is not covered by navigation bar + listView?.let { recyclerView -> + recyclerView.clipToPadding = false + ViewCompat.setOnApplyWindowInsetsListener(recyclerView) { v, insets -> + val systemBars = insets.getInsets(WindowInsetsCompat.Type.systemBars()) + v.updatePadding(bottom = systemBars.bottom) + insets + } + } + } + /** * Show [ListPreference] and [EditTextPreference] dialog by [MaterialAlertDialogBuilder] */ @@ -31,20 +51,22 @@ abstract class BasePreferenceFragment : PreferenceFragmentCompat() { } is EditTextPreference -> { val view = layoutInflater.inflate(R.layout.preference_dialog_edittext_edited, null) - var message = "" - var hint = "" - if (preference.extras.getString("message") != null) { - message = preference.extras.getString("message")!! - } - if (preference.extras.getString("hint") != null) { - hint = preference.extras.getString("hint")!! - } + + // Description/message: Use dialogMessage if set, otherwise check extras val messageView = view.findViewById(android.R.id.message) + val message = preference.dialogMessage?.toString() + ?: preference.extras.getString("message") + ?: "" messageView.text = message + + // Text field: Handle null text by using empty string instead of "null" val editText = view.findViewById(android.R.id.edit) - editText.setText(preference.text.toString()) + val hint = preference.extras.getString("hint") ?: "" + editText.setText(preference.text ?: "") editText.hint = hint - MaterialAlertDialogBuilder(requireContext()) + + // Configure dialog + val dialog = MaterialAlertDialogBuilder(requireContext()) .setTitle(preference.title) .setView(view) .setPositiveButton(android.R.string.ok) { _, _ -> @@ -54,7 +76,15 @@ abstract class BasePreferenceFragment : PreferenceFragmentCompat() { } } .setNegativeButton(android.R.string.cancel, null) - .show() + .create() + + // Show keyboard when dialog is shown + dialog.window?.setSoftInputMode(WindowManager.LayoutParams.SOFT_INPUT_STATE_ALWAYS_VISIBLE) + dialog.setOnShowListener { + editText.requestFocus() + editText.setSelection(editText.text?.length ?: 0) + } + dialog.show() } else -> super.onDisplayPreferenceDialog(preference) } 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 8363360e..b4475100 100644 --- a/app/src/main/java/io/heckel/ntfy/ui/Colors.kt +++ b/app/src/main/java/io/heckel/ntfy/ui/Colors.kt @@ -2,7 +2,6 @@ package io.heckel.ntfy.ui import android.content.Context import android.graphics.Color -import android.os.Build import androidx.core.content.ContextCompat import com.google.android.material.color.MaterialColors import io.heckel.ntfy.R @@ -47,12 +46,7 @@ class Colors { } fun statusBarNormal(context: Context, dynamicColors: Boolean, darkMode: Boolean): Int { - val default = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { - context.resources.getColor(R.color.action_bar, null) - } else { - @Suppress("DEPRECATION") - context.resources.getColor(R.color.action_bar) - } + val default = context.resources.getColor(R.color.action_bar, null) return if (dynamicColors) { // Use colorSurface for both light and dark mode when dynamic colors are enabled MaterialColors.getColor(context, R.attr.colorSurface, default) @@ -90,4 +84,3 @@ class Colors { } } } - diff --git a/app/src/main/java/io/heckel/ntfy/ui/CustomHeaderFragment.kt b/app/src/main/java/io/heckel/ntfy/ui/CustomHeaderFragment.kt index b32297d6..05b946c4 100644 --- a/app/src/main/java/io/heckel/ntfy/ui/CustomHeaderFragment.kt +++ b/app/src/main/java/io/heckel/ntfy/ui/CustomHeaderFragment.kt @@ -103,7 +103,7 @@ class CustomHeaderFragment : DialogFragment() { if (header != null) { dialog .getButton(AlertDialog.BUTTON_NEUTRAL) - .dangerButton(requireContext()) + .dangerButton() } // Validate input when typing diff --git a/app/src/main/java/io/heckel/ntfy/ui/DetailActivity.kt b/app/src/main/java/io/heckel/ntfy/ui/DetailActivity.kt index 9655de35..8a5a5313 100644 --- a/app/src/main/java/io/heckel/ntfy/ui/DetailActivity.kt +++ b/app/src/main/java/io/heckel/ntfy/ui/DetailActivity.kt @@ -7,7 +7,6 @@ import android.content.Context import android.content.Intent import android.content.Intent.ACTION_VIEW import android.net.Uri -import android.os.Build import android.os.Bundle import android.text.Html import android.view.Menu @@ -15,12 +14,16 @@ 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 @@ -56,8 +59,12 @@ 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(), NotificationFragment.NotificationSettingsListener { +class DetailActivity : AppCompatActivity(), NotificationFragment.NotificationSettingsListener, PublishFragment.PublishListener { private val viewModel by viewModels { DetailViewModelFactory((application as Application).repository) } @@ -80,6 +87,11 @@ class DetailActivity : AppCompatActivity(), NotificationFragment.NotificationSet 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 @@ -115,6 +127,7 @@ class DetailActivity : AppCompatActivity(), NotificationFragment.NotificationSet } override fun onCreate(savedInstanceState: Bundle?) { + enableEdgeToEdge() super.onCreate(savedInstanceState) setContentView(R.layout.activity_detail) @@ -137,8 +150,7 @@ class DetailActivity : AppCompatActivity(), NotificationFragment.NotificationSet toolbar.overflowIcon?.setTint(toolbarTextColor) setSupportActionBar(toolbar) - // Set system status bar color and appearance - window.statusBarColor = statusBarColor + // Set system status bar appearance WindowInsetsControllerCompat(window, window.decorView).isAppearanceLightStatusBars = Colors.shouldUseLightStatusBar(dynamicColors, darkMode) @@ -265,11 +277,7 @@ class DetailActivity : AppCompatActivity(), NotificationFragment.NotificationSet howToExample.linksClickable = true val howToText = getString(R.string.detail_how_to_example, topicUrl) - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) { - howToExample.text = Html.fromHtml(howToText, Html.FROM_HTML_MODE_LEGACY) - } else { - howToExample.text = Html.fromHtml(howToText) - } + howToExample.text = Html.fromHtml(howToText, Html.FROM_HTML_MODE_LEGACY) // Swipe to refresh mainListContainer = findViewById(R.id.detail_notification_list_container) @@ -284,6 +292,14 @@ class DetailActivity : AppCompatActivity(), NotificationFragment.NotificationSet 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 { @@ -348,6 +364,126 @@ class DetailActivity : AppCompatActivity(), NotificationFragment.NotificationSet } catch (_: Exception) { // Ignore errors } + + // Setup FAB and message bar + setupPublishUI() + } + + private fun setupPublishUI() { + fab = findViewById(R.id.detail_fab) + messageBar = findViewById(R.id.detail_message_bar) + messageBarText = messageBar.findViewById(R.id.message_bar_text) + messageBarPublishButton = messageBar.findViewById(R.id.message_bar_publish_button) + messageBarExpandButton = messageBar.findViewById(R.id.message_bar_expand_button) + + // Message bar enabled: Show message bar, hide FAB + if (repository.getMessageBarEnabled()) { + fab.visibility = View.GONE + messageBar.visibility = View.VISIBLE + + // Send button click + messageBarPublishButton.setOnClickListener { + publishMessage(messageBarText.text.toString()) // Allow publishing empty messages + } + + // Expand button click opens the full dialog + messageBarExpandButton.setOnClickListener { + openPublishDialog(messageBarText.text.toString()) + } + + // Handle window insets for navigation bar and keyboard + val contentLayout = findViewById(R.id.detail_content_layout) + ViewCompat.setOnApplyWindowInsetsListener(contentLayout) { view, insets -> + val systemBars = insets.getInsets(WindowInsetsCompat.Type.systemBars()) + val ime = insets.getInsets(WindowInsetsCompat.Type.ime()) + // Use the larger of navigation bar or keyboard height + val bottomPadding = maxOf(systemBars.bottom, ime.bottom) + view.setPadding(view.paddingLeft, view.paddingTop, view.paddingRight, bottomPadding) + insets + } + } else { + // Show FAB, hide message bar + fab.visibility = View.VISIBLE + messageBar.visibility = View.GONE + + fab.setOnClickListener { + openPublishDialog("") + } + + // Add bottom padding to FAB to account for navigation bar + ViewCompat.setOnApplyWindowInsetsListener(fab) { view, insets -> + val systemBars = insets.getInsets(WindowInsetsCompat.Type.systemBars()) + val layoutParams = view.layoutParams as androidx.coordinatorlayout.widget.CoordinatorLayout.LayoutParams + layoutParams.bottomMargin = systemBars.bottom + resources.getDimensionPixelSize(R.dimen.fab_margin) + view.layoutParams = layoutParams + insets + } + } + } + + private fun openPublishDialog(initialMessage: String) { + val fragment = PublishFragment.newInstance(subscriptionBaseUrl, subscriptionTopic, subscriptionDisplayName, initialMessage) + fragment.show(supportFragmentManager, PublishFragment.TAG) + } + + private fun publishMessage(message: String) { + // Disable send button while publishing + messageBarPublishButton.isEnabled = false + + lifecycleScope.launch(Dispatchers.IO) { + try { + val user = repository.getUser(subscriptionBaseUrl) + api.publish( + baseUrl = subscriptionBaseUrl, + topic = subscriptionTopic, + user = user, + 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 -> { + if (e.user != null) { + getString(R.string.detail_test_message_error_unauthorized_user, e.user.username) + } else { + 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() + } + } + } + } + + /** + * Called by the publish dialog (PublishFragment) after the notification + * was successfully published. + */ + override fun onPublished() { + // Clear the message bar text when a message is published from the dialog + if (this::messageBarText.isInitialized) { + messageBarText.text?.clear() + } } override fun onResume() { @@ -684,7 +820,7 @@ class DetailActivity : AppCompatActivity(), NotificationFragment.NotificationSet dialog.setOnShowListener { dialog .getButton(AlertDialog.BUTTON_POSITIVE) - .dangerButton(this) + .dangerButton() } dialog.show() } @@ -721,7 +857,7 @@ class DetailActivity : AppCompatActivity(), NotificationFragment.NotificationSet dialog.setOnShowListener { dialog .getButton(AlertDialog.BUTTON_POSITIVE) - .dangerButton(this) + .dangerButton() } dialog.show() } @@ -731,7 +867,7 @@ class DetailActivity : AppCompatActivity(), NotificationFragment.NotificationSet handleActionModeClick(notification) } else if (notification.click != "") { try { - startActivity(Intent(ACTION_VIEW, Uri.parse(notification.click))) + startActivity(Intent(ACTION_VIEW, notification.click.toUri())) } catch (e: Exception) { Log.w(TAG, "Cannot open click URL", e) runOnUiThread { @@ -804,7 +940,7 @@ class DetailActivity : AppCompatActivity(), NotificationFragment.NotificationSet dialog.setOnShowListener { dialog .getButton(AlertDialog.BUTTON_POSITIVE) - .dangerButton(this) + .dangerButton() } dialog.show() } diff --git a/app/src/main/java/io/heckel/ntfy/ui/DetailAdapter.kt b/app/src/main/java/io/heckel/ntfy/ui/DetailAdapter.kt index 0a0c6556..6383a80b 100644 --- a/app/src/main/java/io/heckel/ntfy/ui/DetailAdapter.kt +++ b/app/src/main/java/io/heckel/ntfy/ui/DetailAdapter.kt @@ -5,11 +5,9 @@ import android.app.Activity import android.content.* import android.content.pm.PackageManager import android.graphics.Bitmap -import android.net.Uri import android.os.Build import android.os.Environment import android.provider.MediaStore -import android.text.method.LinkMovementMethod import android.text.util.Linkify import android.view.LayoutInflater import android.view.View @@ -42,6 +40,7 @@ import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.GlobalScope import kotlinx.coroutines.launch import me.saket.bettermovementmethod.BetterLinkMovementMethod +import androidx.core.net.toUri class DetailAdapter(private val activity: Activity, private val lifecycleScope: CoroutineScope, private val repository: Repository, private val onClick: (Notification) -> Unit, private val onLongClick: (Notification) -> Unit) : ListAdapter(TopicDiffCallback) { @@ -71,7 +70,7 @@ class DetailAdapter(private val activity: Activity, private val lifecycleScope: selected.add(notificationId) } - if (selected.size != 0) { + if (selected.isNotEmpty()) { val listIds = currentList.map { notification -> notification.id } val notificationPosition = listIds.indexOf(notificationId) notifyItemChanged(notificationPosition) @@ -205,7 +204,7 @@ class DetailAdapter(private val activity: Activity, private val lifecycleScope: } val attachment = notification.attachment val image = attachment.contentUri != null && supportedImage(attachment.type) && previewableImage(attachmentFileStat) - val bitmap = if (image) attachment.contentUri?.readBitmapFromUriOrNull(context) else null + val bitmap = if (image) attachment.contentUri.readBitmapFromUriOrNull(context) else null maybeRenderAttachmentImage(context, bitmap, attachment) maybeRenderAttachmentBox(context, notification, attachment, attachmentFileStat, bitmap) } @@ -351,7 +350,7 @@ class DetailAdapter(private val activity: Activity, private val lifecycleScope: if (expired) { infos.add(context.getString(R.string.detail_item_download_info_not_downloaded_expired)) } else if (expires) { - infos.add(context.getString(R.string.detail_item_download_info_not_downloaded_expires_x, formatDateShort(attachment.expires!!))) + infos.add(context.getString(R.string.detail_item_download_info_not_downloaded_expires_x, formatDateShort(attachment.expires))) } else { infos.add(context.getString(R.string.detail_item_download_info_not_downloaded)) } @@ -361,7 +360,7 @@ class DetailAdapter(private val activity: Activity, private val lifecycleScope: if (expired) { infos.add(context.getString(R.string.detail_item_download_info_deleted_expired)) } else if (expires) { - infos.add(context.getString(R.string.detail_item_download_info_deleted_expires_x, formatDateShort(attachment.expires!!))) + infos.add(context.getString(R.string.detail_item_download_info_deleted_expires_x, formatDateShort(attachment.expires))) } else { infos.add(context.getString(R.string.detail_item_download_info_deleted)) } @@ -369,12 +368,12 @@ class DetailAdapter(private val activity: Activity, private val lifecycleScope: if (expired) { infos.add(context.getString(R.string.detail_item_download_info_download_failed_expired)) } else if (expires) { - infos.add(context.getString(R.string.detail_item_download_info_download_failed_expires_x, formatDateShort(attachment.expires!!))) + infos.add(context.getString(R.string.detail_item_download_info_download_failed_expires_x, formatDateShort(attachment.expires))) } else { infos.add(context.getString(R.string.detail_item_download_info_download_failed)) } } - return if (infos.size > 0) { + return if (infos.isNotEmpty()) { "$name\n${infos.joinToString(", ")}" } else { name @@ -389,7 +388,7 @@ class DetailAdapter(private val activity: Activity, private val lifecycleScope: try { Glide.with(context).load(attachment.contentUri).fitCenter().into(attachmentImageView) attachmentImageView.setOnClickListener { - StfalconImageViewer.Builder(context, listOf(bitmap)) { imageView, image -> + StfalconImageViewer.Builder(context, listOf(bitmap)) { imageView, _ -> Glide.with(context).load(attachment.contentUri).into(imageView) } .allowZooming(true) @@ -412,12 +411,12 @@ class DetailAdapter(private val activity: Activity, private val lifecycleScope: } Log.d(TAG, "Opening file ${attachment.contentUri}") try { - val contentUri = Uri.parse(attachment.contentUri) + val contentUri = attachment.contentUri?.toUri() val intent = Intent(Intent.ACTION_VIEW, contentUri) intent.setDataAndType(contentUri, attachment.type ?: "application/octet-stream") // Required for Android <= P intent.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) context.startActivity(intent) - } catch (e: ActivityNotFoundException) { + } catch (_: ActivityNotFoundException) { Toast .makeText(context, context.getString(R.string.detail_item_cannot_open_not_found), Toast.LENGTH_LONG) .show() @@ -444,7 +443,7 @@ class DetailAdapter(private val activity: Activity, private val lifecycleScope: put(MediaStore.MediaColumns.IS_PENDING, 1) // While downloading } } - val inUri = Uri.parse(attachment.contentUri) + val inUri = attachment.contentUri!!.toUri() val inFile = resolver.openInputStream(inUri) ?: throw Exception("Cannot open input stream") val outUri = if (Build.VERSION.SDK_INT <= Build.VERSION_CODES.P) { val file = ensureSafeNewFile(Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS), attachment.name) @@ -475,7 +474,7 @@ class DetailAdapter(private val activity: Activity, private val lifecycleScope: private fun deleteFile(context: Context, notification: Notification, attachment: Attachment): Boolean { try { - val contentUri = Uri.parse(attachment.contentUri) + val contentUri = attachment.contentUri!!.toUri() val resolver = context.applicationContext.contentResolver val deleted = resolver.delete(contentUri, null, null) > 0 if (!deleted) throw Exception("no rows deleted") @@ -537,7 +536,7 @@ class DetailAdapter(private val activity: Activity, private val lifecycleScope: private fun runViewAction(context: Context, action: Action) { try { val url = action.url ?: return - val intent = Intent(Intent.ACTION_VIEW, Uri.parse(url)).apply { + val intent = Intent(Intent.ACTION_VIEW, url.toUri()).apply { addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) } context.startActivity(intent) diff --git a/app/src/main/java/io/heckel/ntfy/ui/DetailSettingsActivity.kt b/app/src/main/java/io/heckel/ntfy/ui/DetailSettingsActivity.kt index e012ba04..ae2b0509 100644 --- a/app/src/main/java/io/heckel/ntfy/ui/DetailSettingsActivity.kt +++ b/app/src/main/java/io/heckel/ntfy/ui/DetailSettingsActivity.kt @@ -10,6 +10,7 @@ import android.os.Bundle import android.provider.Settings import android.text.TextUtils import android.widget.Toast +import androidx.activity.enableEdgeToEdge import androidx.activity.result.ActivityResultLauncher import androidx.activity.result.contract.ActivityResultContracts import androidx.appcompat.app.AppCompatActivity @@ -32,6 +33,7 @@ import kotlinx.coroutines.* import java.io.File import java.io.IOException import java.util.* +import androidx.core.net.toUri /** * Subscription settings @@ -44,6 +46,7 @@ class DetailSettingsActivity : AppCompatActivity() { private var subscriptionId: Long = 0 override fun onCreate(savedInstanceState: Bundle?) { + enableEdgeToEdge() super.onCreate(savedInstanceState) setContentView(R.layout.activity_settings) @@ -78,8 +81,7 @@ class DetailSettingsActivity : AppCompatActivity() { toolbar.overflowIcon?.setTint(toolbarTextColor) setSupportActionBar(toolbar) - // Set system status bar color and appearance - window.statusBarColor = statusBarColor + // Set system status bar appearance WindowInsetsControllerCompat(window, window.decorView).isAppearanceLightStatusBars = Colors.shouldUseLightStatusBar(dynamicColors, darkMode) @@ -96,7 +98,7 @@ class DetailSettingsActivity : AppCompatActivity() { return true } - class SettingsFragment : PreferenceFragmentCompat() { + class SettingsFragment : BasePreferenceFragment() { private lateinit var resolver: ContentResolver private lateinit var repository: Repository private lateinit var serviceManager: SubscriberServiceManager @@ -143,10 +145,8 @@ class DetailSettingsActivity : AppCompatActivity() { loadInsistentMaxPriorityPref() loadIconSetPref() loadIconRemovePref() - if (notificationService.channelsSupported()) { - loadDedicatedChannelsPrefs() - loadOpenChannelsPrefs() - } + loadDedicatedChannelsPrefs() + loadOpenChannelsPrefs() } else { val notificationsHeaderId = context?.getString(R.string.detail_settings_notifications_header_key) ?: return val notificationsHeader: PreferenceCategory? = findPreference(notificationsHeaderId) @@ -507,7 +507,7 @@ class DetailSettingsActivity : AppCompatActivity() { return } try { - resolver.delete(Uri.parse(uri), null, null) + resolver.delete(uri.toUri(), null, null) } catch (e: Exception) { Log.w(TAG, "Unable to delete $uri", e) } 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 6d12f424..8c8c1020 100644 --- a/app/src/main/java/io/heckel/ntfy/ui/MainActivity.kt +++ b/app/src/main/java/io/heckel/ntfy/ui/MainActivity.kt @@ -8,7 +8,6 @@ import android.app.AlertDialog import android.content.ActivityNotFoundException import android.content.Intent import android.content.pm.PackageManager -import android.net.Uri import android.os.Build import android.os.Bundle import android.provider.Settings @@ -20,6 +19,7 @@ import android.view.View import android.widget.Button import android.widget.TextView import android.widget.Toast +import androidx.activity.enableEdgeToEdge import androidx.activity.viewModels import androidx.appcompat.app.AppCompatActivity import androidx.appcompat.app.AppCompatDelegate @@ -31,6 +31,7 @@ import androidx.core.text.HtmlCompat import androidx.core.view.ViewCompat import androidx.core.view.WindowInsetsCompat import androidx.core.view.WindowInsetsControllerCompat +import androidx.core.view.updatePadding import androidx.core.view.isVisible import androidx.lifecycle.lifecycleScope import androidx.recyclerview.widget.RecyclerView @@ -75,6 +76,7 @@ import java.util.concurrent.TimeUnit import kotlin.random.Random import androidx.core.view.size import androidx.core.view.get +import androidx.core.net.toUri class MainActivity : AppCompatActivity(), AddFragment.SubscribeListener, NotificationFragment.NotificationSettingsListener { private val viewModel by viewModels { @@ -126,6 +128,7 @@ class MainActivity : AppCompatActivity(), AddFragment.SubscribeListener, Notific } override fun onCreate(savedInstanceState: Bundle?) { + enableEdgeToEdge() super.onCreate(savedInstanceState) setContentView(R.layout.activity_main) @@ -152,8 +155,7 @@ class MainActivity : AppCompatActivity(), AddFragment.SubscribeListener, Notific setSupportActionBar(toolbar) title = getString(R.string.main_action_bar_title) - // Set system status bar color and appearance - window.statusBarColor = statusBarColor + // Set system status bar appearance WindowInsetsControllerCompat(window, window.decorView).isAppearanceLightStatusBars = Colors.shouldUseLightStatusBar(dynamicColors, darkMode) @@ -193,6 +195,14 @@ class MainActivity : AppCompatActivity(), AddFragment.SubscribeListener, Notific Colors.onPrimary(this) ) 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().observe(this) { it?.let { subscriptions -> @@ -257,27 +267,24 @@ class MainActivity : AppCompatActivity(), AddFragment.SubscribeListener, Notific repository.setBatteryOptimizationsRemindTime(System.currentTimeMillis() + ONE_DAY_MILLIS) } fixNowButton.setOnClickListener { - // It should not be visible for SDK < 23 - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { - try { - Log.d(TAG, Uri.parse("package:$packageName").toString()) - startActivity( - Intent( - Settings.ACTION_REQUEST_IGNORE_BATTERY_OPTIMIZATIONS, - Uri.parse("package:$packageName") - ) + try { + Log.d(TAG, "package:$packageName".toUri().toString()) + startActivity( + Intent( + Settings.ACTION_REQUEST_IGNORE_BATTERY_OPTIMIZATIONS, + "package:$packageName".toUri() ) - } catch (e: ActivityNotFoundException) { - try { - startActivity(Intent(Settings.ACTION_IGNORE_BATTERY_OPTIMIZATION_SETTINGS)) - } catch (e2: ActivityNotFoundException) { - startActivity(Intent(Settings.ACTION_SETTINGS)) - } + ) + } catch (_: ActivityNotFoundException) { + try { + startActivity(Intent(Settings.ACTION_IGNORE_BATTERY_OPTIMIZATION_SETTINGS)) + } catch (_: ActivityNotFoundException) { + startActivity(Intent(Settings.ACTION_SETTINGS)) } - // Hide, at least for now - val batteryBanner = findViewById(R.id.main_banner_battery) - batteryBanner.visibility = View.GONE } + // Hide, at least for now + val batteryBanner = findViewById(R.id.main_banner_battery) + batteryBanner.visibility = View.GONE } // WebSocket banner @@ -509,7 +516,9 @@ class MainActivity : AppCompatActivity(), AddFragment.SubscribeListener, Notific } } if (rerenderList) { - redrawList() + mainList.post { + redrawList() + } } } } @@ -561,19 +570,27 @@ class MainActivity : AppCompatActivity(), AddFragment.SubscribeListener, Notific true } R.id.main_menu_report_bug -> { - startActivity(Intent(Intent.ACTION_VIEW, Uri.parse(getString(R.string.main_menu_report_bug_url)))) + startActivity( + Intent(Intent.ACTION_VIEW, getString(R.string.main_menu_report_bug_url).toUri()) + ) true } R.id.main_menu_rate -> { try { - startActivity(Intent(Intent.ACTION_VIEW, Uri.parse("market://details?id=$packageName"))) - } catch (e: ActivityNotFoundException) { - startActivity(Intent(Intent.ACTION_VIEW, Uri.parse("https://play.google.com/store/apps/details?id=$packageName"))) + startActivity( + Intent(Intent.ACTION_VIEW, "market://details?id=$packageName".toUri()) + ) + } catch (_: ActivityNotFoundException) { + startActivity( + Intent(Intent.ACTION_VIEW, "https://play.google.com/store/apps/details?id=$packageName".toUri()) + ) } true } R.id.main_menu_docs -> { - startActivity(Intent(Intent.ACTION_VIEW, Uri.parse(getString(R.string.main_menu_docs_url)))) + startActivity( + Intent(Intent.ACTION_VIEW, getString(R.string.main_menu_docs_url).toUri()) + ) true } else -> super.onOptionsItemSelected(item) @@ -686,7 +703,7 @@ class MainActivity : AppCompatActivity(), AddFragment.SubscribeListener, Notific var errorMessage = "" // First error var newNotificationsCount = 0 repository.getSubscriptions().forEach { subscription -> - Log.d(TAG, "subscription: ${subscription}") + Log.d(TAG, "subscription: $subscription") try { val user = repository.getUser(subscription.baseUrl) // May be null val notifications = api.poll(subscription.id, subscription.baseUrl, subscription.topic, user, subscription.lastNotificationId) @@ -768,7 +785,7 @@ class MainActivity : AppCompatActivity(), AddFragment.SubscribeListener, Notific dialog.setOnShowListener { dialog .getButton(AlertDialog.BUTTON_POSITIVE) - .dangerButton(this) + .dangerButton() } dialog.show() } diff --git a/app/src/main/java/io/heckel/ntfy/ui/MainAdapter.kt b/app/src/main/java/io/heckel/ntfy/ui/MainAdapter.kt index d9d08434..b128658f 100644 --- a/app/src/main/java/io/heckel/ntfy/ui/MainAdapter.kt +++ b/app/src/main/java/io/heckel/ntfy/ui/MainAdapter.kt @@ -51,7 +51,7 @@ class MainAdapter( selected.add(subscriptionId) } - if (selected.size != 0) { + if (selected.isNotEmpty()) { val listIds = currentList.map { subscription -> subscription.id } val subscriptionPosition = listIds.indexOf(subscriptionId) notifyItemChanged(subscriptionPosition) diff --git a/app/src/main/java/io/heckel/ntfy/ui/MainViewModel.kt b/app/src/main/java/io/heckel/ntfy/ui/MainViewModel.kt index 19e384c9..c5f48d79 100644 --- a/app/src/main/java/io/heckel/ntfy/ui/MainViewModel.kt +++ b/app/src/main/java/io/heckel/ntfy/ui/MainViewModel.kt @@ -1,7 +1,6 @@ package io.heckel.ntfy.ui import android.content.Context -import android.net.Uri import androidx.lifecycle.LiveData import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModelProvider @@ -10,6 +9,7 @@ import io.heckel.ntfy.db.* import io.heckel.ntfy.up.Distributor import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch +import androidx.core.net.toUri class SubscriptionsViewModel(private val repository: Repository) : ViewModel() { fun list(): LiveData> { @@ -35,7 +35,7 @@ class SubscriptionsViewModel(private val repository: Repository) : ViewModel() { if (subscription.icon != null) { val resolver = context.applicationContext.contentResolver try { - resolver.delete(Uri.parse(subscription.icon), null, null) + resolver.delete(subscription.icon.toUri(), null, null) } catch (_: Exception) { // Don't care } diff --git a/app/src/main/java/io/heckel/ntfy/ui/NotificationFragment.kt b/app/src/main/java/io/heckel/ntfy/ui/NotificationFragment.kt index 920d1bd4..a55697f0 100644 --- a/app/src/main/java/io/heckel/ntfy/ui/NotificationFragment.kt +++ b/app/src/main/java/io/heckel/ntfy/ui/NotificationFragment.kt @@ -1,6 +1,5 @@ package io.heckel.ntfy.ui -import android.app.AlertDialog import android.app.Dialog import android.content.Context import android.os.Bundle diff --git a/app/src/main/java/io/heckel/ntfy/ui/PriorityAdapter.kt b/app/src/main/java/io/heckel/ntfy/ui/PriorityAdapter.kt new file mode 100644 index 00000000..96d2b189 --- /dev/null +++ b/app/src/main/java/io/heckel/ntfy/ui/PriorityAdapter.kt @@ -0,0 +1,58 @@ +package io.heckel.ntfy.ui + +import android.content.Context +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import android.widget.ArrayAdapter +import android.widget.ImageView +import android.widget.TextView +import io.heckel.ntfy.R + +data class PriorityItem( + val priority: Int, + val label: String, + val iconResId: Int +) { + override fun toString(): String = label +} + +class PriorityAdapter( + context: Context, + private val items: List +) : ArrayAdapter(context, R.layout.item_priority_dropdown, items) { + + override fun getView(position: Int, convertView: View?, parent: ViewGroup): View { + return createItemView(position, convertView, parent) + } + + override fun getDropDownView(position: Int, convertView: View?, parent: ViewGroup): View { + return createItemView(position, convertView, parent) + } + + private fun createItemView(position: Int, convertView: View?, parent: ViewGroup): View { + val view = convertView ?: LayoutInflater.from(context) + .inflate(R.layout.item_priority_dropdown, parent, false) + + val item = items[position] + val iconView = view.findViewById(R.id.priority_icon) + val textView = view.findViewById(R.id.priority_text) + + iconView.setImageResource(item.iconResId) + textView.text = item.label + + return view + } + + companion object { + fun createPriorityItems(context: Context): List { + return listOf( + PriorityItem(5, context.getString(R.string.publish_dialog_priority_max), R.drawable.ic_priority_5_24dp), + PriorityItem(4, context.getString(R.string.publish_dialog_priority_high), R.drawable.ic_priority_4_24dp), + PriorityItem(3, context.getString(R.string.publish_dialog_priority_default), R.drawable.ic_priority_3_24dp), + PriorityItem(2, context.getString(R.string.publish_dialog_priority_low), R.drawable.ic_priority_2_24dp), + PriorityItem(1, context.getString(R.string.publish_dialog_priority_min), R.drawable.ic_priority_1_24dp) + ) + } + } +} diff --git a/app/src/main/java/io/heckel/ntfy/ui/PublishFragment.kt b/app/src/main/java/io/heckel/ntfy/ui/PublishFragment.kt new file mode 100644 index 00000000..63a51f91 --- /dev/null +++ b/app/src/main/java/io/heckel/ntfy/ui/PublishFragment.kt @@ -0,0 +1,671 @@ +package io.heckel.ntfy.ui + +import android.app.Dialog +import android.content.Context +import android.content.Intent +import android.net.Uri +import android.os.Bundle +import android.provider.OpenableColumns +import android.view.MenuItem +import android.view.View +import android.view.ViewGroup +import android.view.inputmethod.InputMethodManager +import android.text.method.LinkMovementMethod +import android.widget.AutoCompleteTextView +import android.widget.TextView +import com.google.android.material.progressindicator.LinearProgressIndicator +import android.widget.Toast +import androidx.activity.result.ActivityResultLauncher +import androidx.activity.result.contract.ActivityResultContracts +import androidx.core.content.ContextCompat +import androidx.core.view.isVisible +import androidx.fragment.app.DialogFragment +import androidx.lifecycle.lifecycleScope +import com.google.android.material.appbar.MaterialToolbar +import com.google.android.material.textfield.TextInputLayout +import io.heckel.ntfy.BuildConfig +import com.google.android.material.chip.Chip +import com.google.android.material.chip.ChipGroup +import com.google.android.material.textfield.TextInputEditText +import android.widget.ImageView +import okhttp3.MediaType +import okhttp3.RequestBody +import okio.BufferedSink +import okio.source +import io.heckel.ntfy.R +import io.heckel.ntfy.db.Repository +import io.heckel.ntfy.msg.ApiService +import io.heckel.ntfy.util.Log +import io.heckel.ntfy.util.AfterChangedTextWatcher +import io.heckel.ntfy.util.formatBytes +import io.heckel.ntfy.util.mimeTypeToIconResource +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import io.heckel.ntfy.util.ProgressRequestBody +import okhttp3.MediaType.Companion.toMediaType + +class PublishFragment : DialogFragment() { + private lateinit var api: ApiService + private lateinit var repository: Repository + + // Toolbar + private lateinit var toolbar: MaterialToolbar + private lateinit var publishMenuItem: MenuItem + + // Main fields + private lateinit var titleText: TextInputEditText + private lateinit var messageText: TextInputEditText + private lateinit var tagsText: TextInputEditText + private lateinit var priorityDropdown: AutoCompleteTextView + + // Chips + private lateinit var chipGroup: ChipGroup + private lateinit var chipTitle: Chip + private lateinit var chipTags: Chip + private lateinit var chipPriority: Chip + private lateinit var chipMarkdown: Chip + private lateinit var chipClickUrl: Chip + private lateinit var chipEmail: Chip + private lateinit var chipDelay: Chip + private lateinit var chipAttachUrl: Chip + private lateinit var chipAttachFile: Chip + private lateinit var chipPhoneCall: Chip + + // Toggleable field layouts + private lateinit var titleLayout: View + private lateinit var tagsLayout: View + private lateinit var priorityLayout: View + + // Optional field layouts + private lateinit var clickUrlLayout: View + private lateinit var emailLayout: View + private lateinit var delayLayout: View + private lateinit var attachUrlLayout: View + private lateinit var phoneCallLayout: View + + // Optional field inputs + private lateinit var clickUrlText: TextInputEditText + private lateinit var emailText: TextInputEditText + private lateinit var delayText: TextInputEditText + private lateinit var attachUrlText: TextInputEditText + private lateinit var attachFilenameText: TextInputEditText + private lateinit var attachFilenameLayout: TextInputLayout + private lateinit var phoneCallText: TextInputEditText + + // Attachment box (shown after file is selected) + private lateinit var attachmentBox: View + private lateinit var attachmentBoxIcon: ImageView + private lateinit var attachmentBoxFilenameText: TextInputEditText + private lateinit var attachmentBoxSize: TextView + + // Progress/Error + private lateinit var uploadProgress: LinearProgressIndicator + private lateinit var uploadProgressText: TextView + private lateinit var errorText: TextView + private lateinit var errorImage: View + private lateinit var docsLink: TextView + + // Job and cancel function (represents active publish HTTP call) + private var job: Job? = null + private var cancelFn: (() -> Unit)? = null + private var publishing: Boolean = false + + // State + private var baseUrl: String = "" + private var topic: String = "" + private var displayName: String = "" + private var selectedPriority: Int = 3 // Default priority + private var initialMessage: String = "" + private var selectedFileUri: Uri? = null + private var selectedFileName: String = "" + private var selectedFileSize: Long = 0 + private var selectedFileMimeType: String = "application/octet-stream" + + // File picker + private lateinit var filePickerLauncher: ActivityResultLauncher + + // Implemented by the DetailActivity, allows us to let it know when the message is published + interface PublishListener { + fun onPublished() + } + + private var publishListener: PublishListener? = null + + override fun onAttach(context: Context) { + super.onAttach(context) + if (context is PublishListener) { + publishListener = context + } + } + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + filePickerLauncher = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result -> + if (result.resultCode == android.app.Activity.RESULT_OK) { + result.data?.data?.let { uri -> + handleSelectedFile(uri) + } + } else { + // User cancelled file picker, uncheck the chip + chipAttachFile.isChecked = false + } + } + } + + override fun onCreateDialog(savedInstanceState: Bundle?): Dialog { + if (activity == null) { + throw IllegalStateException("Activity cannot be null") + } + + // Dependencies + repository = Repository.getInstance(requireActivity()) + api = ApiService(requireContext()) + + // Get arguments + baseUrl = arguments?.getString(ARG_BASE_URL) ?: "" + topic = arguments?.getString(ARG_TOPIC) ?: "" + displayName = arguments?.getString(ARG_DISPLAY_NAME) ?: "" + initialMessage = arguments?.getString(ARG_MESSAGE) ?: "" + + // Build root view + val view = requireActivity().layoutInflater.inflate(R.layout.fragment_publish_dialog, null) + + // Setup toolbar + toolbar = view.findViewById(R.id.publish_dialog_toolbar) + toolbar.title = getString(R.string.publish_dialog_title, displayName) + toolbar.setNavigationOnClickListener { + if (publishing) { + cancel() + } else { + dismiss() + } + } + toolbar.setOnMenuItemClickListener { menuItem -> + if (menuItem.itemId == R.id.publish_dialog_publish_button) { + onSendClick() + true + } else { + false + } + } + publishMenuItem = toolbar.menu.findItem(R.id.publish_dialog_publish_button) + + // Main fields + titleText = view.findViewById(R.id.publish_dialog_title_text) + messageText = view.findViewById(R.id.publish_dialog_message_text) + tagsText = view.findViewById(R.id.publish_dialog_tags_text) + priorityDropdown = view.findViewById(R.id.publish_dialog_priority_dropdown) + uploadProgress = view.findViewById(R.id.publish_dialog_upload_progress) + uploadProgressText = view.findViewById(R.id.publish_dialog_upload_progress_text) + errorText = view.findViewById(R.id.publish_dialog_error_text) + errorImage = view.findViewById(R.id.publish_dialog_error_image) + docsLink = view.findViewById(R.id.publish_dialog_docs_text) + docsLink.movementMethod = LinkMovementMethod.getInstance() + docsLink.isVisible = BuildConfig.PAYMENT_LINKS_AVAILABLE + + // Set initial message if provided and place cursor at end + if (initialMessage.isNotEmpty()) { + messageText.setText(initialMessage) + messageText.setSelection(initialMessage.length) + } + + // Setup priority dropdown with custom adapter + val priorityItems = PriorityAdapter.createPriorityItems(requireContext()) + val priorityAdapter = PriorityAdapter(requireContext(), priorityItems) + priorityDropdown.setAdapter(priorityAdapter) + priorityDropdown.setText(priorityItems[2].label, false) // Set default priority (index 2 -> priority 3) + updatePriorityIcon(priorityItems[2].iconResId) + priorityDropdown.setOnItemClickListener { _, _, position, _ -> + selectedPriority = priorityItems[position].priority + priorityDropdown.setText(priorityItems[position].label, false) + updatePriorityIcon(priorityItems[position].iconResId) + } + + // Setup chips + chipGroup = view.findViewById(R.id.publish_dialog_chip_group) + chipTitle = view.findViewById(R.id.publish_dialog_chip_title) + chipTags = view.findViewById(R.id.publish_dialog_chip_tags) + chipPriority = view.findViewById(R.id.publish_dialog_chip_priority) + chipMarkdown = view.findViewById(R.id.publish_dialog_chip_markdown) + chipClickUrl = view.findViewById(R.id.publish_dialog_chip_click_url) + chipEmail = view.findViewById(R.id.publish_dialog_chip_email) + chipDelay = view.findViewById(R.id.publish_dialog_chip_delay) + chipAttachUrl = view.findViewById(R.id.publish_dialog_chip_attach_url) + chipAttachFile = view.findViewById(R.id.publish_dialog_chip_attach_file) + chipPhoneCall = view.findViewById(R.id.publish_dialog_chip_phone_call) + + // Setup toggleable field layouts + titleLayout = view.findViewById(R.id.publish_dialog_title_layout) + tagsLayout = view.findViewById(R.id.publish_dialog_tags_layout) + priorityLayout = view.findViewById(R.id.publish_dialog_priority_layout) + + // Setup optional field layouts + clickUrlLayout = view.findViewById(R.id.publish_dialog_click_url_layout) + emailLayout = view.findViewById(R.id.publish_dialog_email_layout) + delayLayout = view.findViewById(R.id.publish_dialog_delay_layout) + attachUrlLayout = view.findViewById(R.id.publish_dialog_attach_url_layout) + phoneCallLayout = view.findViewById(R.id.publish_dialog_phone_call_layout) + + // Setup optional field inputs + clickUrlText = view.findViewById(R.id.publish_dialog_click_url_text) + emailText = view.findViewById(R.id.publish_dialog_email_text) + delayText = view.findViewById(R.id.publish_dialog_delay_text) + attachUrlText = view.findViewById(R.id.publish_dialog_attach_url_text) + attachFilenameText = view.findViewById(R.id.publish_dialog_attach_filename_text) + attachFilenameLayout = view.findViewById(R.id.publish_dialog_attach_filename_layout) + phoneCallText = view.findViewById(R.id.publish_dialog_phone_call_text) + + // Attachment box (shown after file is selected) + attachmentBox = view.findViewById(R.id.publish_dialog_attachment_box) + attachmentBoxIcon = attachmentBox.findViewById(R.id.attachment_box_icon) + attachmentBoxFilenameText = attachmentBox.findViewById(R.id.attachment_box_filename) + attachmentBoxSize = attachmentBox.findViewById(R.id.attachment_box_size) + + // Setup chip click listeners + setupChipListeners() + + // Validation on text change + val textWatcher = AfterChangedTextWatcher { + validateInput() + } + messageText.addTextChangedListener(textWatcher) + + // Build dialog + val dialog = Dialog(requireContext(), R.style.Theme_App_FullScreenDialog) + dialog.setContentView(view) + + // Initial validation + validateInput() + + return dialog + } + + private fun setupChipListeners() { + chipTitle.setOnCheckedChangeListener { _, isChecked -> + titleLayout.visibility = if (isChecked) View.VISIBLE else View.GONE + if (isChecked) { + titleText.requestFocus() + showKeyboard(titleText) + } else { + titleText.setText("") + } + } + + chipTags.setOnCheckedChangeListener { _, isChecked -> + tagsLayout.visibility = if (isChecked) View.VISIBLE else View.GONE + if (isChecked) { + tagsText.requestFocus() + showKeyboard(tagsText) + } else { + tagsText.setText("") + } + } + + chipPriority.setOnCheckedChangeListener { _, isChecked -> + priorityLayout.visibility = if (isChecked) View.VISIBLE else View.GONE + if (isChecked) { + hideKeyboard() // FIXME: This does not seem to hide the keyboard + priorityDropdown.requestFocus() + priorityDropdown.showDropDown() + } else { + // Reset to default priority + selectedPriority = 3 + val priorityItems = PriorityAdapter.createPriorityItems(requireContext()) + priorityDropdown.setText(priorityItems[2].label, false) + updatePriorityIcon(priorityItems[2].iconResId) + } + } + + chipClickUrl.setOnCheckedChangeListener { _, isChecked -> + clickUrlLayout.visibility = if (isChecked) View.VISIBLE else View.GONE + if (isChecked) { + clickUrlText.requestFocus() + showKeyboard(clickUrlText) + } else { + clickUrlText.setText("") + } + } + + chipEmail.setOnCheckedChangeListener { _, isChecked -> + emailLayout.visibility = if (isChecked) View.VISIBLE else View.GONE + if (isChecked) { + emailText.requestFocus() + showKeyboard(emailText) + } else { + emailText.setText("") + } + } + + chipDelay.setOnCheckedChangeListener { _, isChecked -> + delayLayout.visibility = if (isChecked) View.VISIBLE else View.GONE + if (isChecked) { + delayText.requestFocus() + showKeyboard(delayText) + } else { + delayText.setText("") + } + } + + chipAttachUrl.setOnCheckedChangeListener { _, isChecked -> + attachUrlLayout.visibility = if (isChecked) View.VISIBLE else View.GONE + attachFilenameLayout.visibility = if (isChecked) View.VISIBLE else View.GONE + if (isChecked) { + // Mutually exclusive with attach file + chipAttachFile.isChecked = false + attachUrlText.requestFocus() + showKeyboard(attachUrlText) + } else { + attachUrlText.setText("") + attachFilenameText.setText("") + } + } + + chipAttachFile.setOnCheckedChangeListener { _, isChecked -> + if (isChecked) { + // Mutually exclusive with attach URL + chipAttachUrl.isChecked = false + // Open file picker immediately (don't show any UI yet) + openFilePicker() + } else { + selectedFileUri = null + selectedFileName = "" + selectedFileSize = 0 + attachmentBox.visibility = View.GONE + attachmentBoxFilenameText.setText("") + } + } + + chipPhoneCall.setOnCheckedChangeListener { _, isChecked -> + phoneCallLayout.visibility = if (isChecked) View.VISIBLE else View.GONE + if (isChecked) { + phoneCallText.requestFocus() + showKeyboard(phoneCallText) + } else { + phoneCallText.setText("") + } + } + } + + private fun createFileRequestBody(): RequestBody { + val fileUri = selectedFileUri!! + val mimeType = selectedFileMimeType.toMediaType() + val fileSize = selectedFileSize + val context = requireContext() + + val baseBody = object : RequestBody() { + override fun contentType(): MediaType = mimeType + override fun contentLength(): Long = fileSize + override fun writeTo(sink: BufferedSink) { + context.contentResolver.openInputStream(fileUri)?.use { inputStream -> + sink.writeAll(inputStream.source()) + } + } + } + + // Wrap with progress tracking + return ProgressRequestBody(baseBody) { bytesWritten, totalBytes -> + val percent = if (totalBytes > 0) (bytesWritten * 100 / totalBytes).toInt() else 0 + activity?.runOnUiThread { + if (!isAdded) return@runOnUiThread + uploadProgress.progress = percent + uploadProgressText.text = getString( + R.string.publish_dialog_uploading, + "$percent%", + formatBytes(bytesWritten), + formatBytes(totalBytes) + ) + } + } + } + + private fun showKeyboard(view: View) { + view.postDelayed({ + val imm = requireContext().getSystemService(Context.INPUT_METHOD_SERVICE) as? InputMethodManager + imm?.showSoftInput(view, InputMethodManager.SHOW_IMPLICIT) + }, 100) + } + + private fun hideKeyboard() { + val imm = requireContext().getSystemService(Context.INPUT_METHOD_SERVICE) as? InputMethodManager + imm?.hideSoftInputFromWindow(view?.windowToken, 0) + } + + private fun updatePriorityIcon(iconResId: Int) { + val drawable = ContextCompat.getDrawable(requireContext(), iconResId) + drawable?.setBounds(0, 0, drawable.intrinsicWidth, drawable.intrinsicHeight) + priorityDropdown.setCompoundDrawablesRelative(drawable, null, null, null) + priorityDropdown.compoundDrawablePadding = (12 * resources.displayMetrics.density).toInt() + } + + private fun openFilePicker() { + val intent = Intent(Intent.ACTION_OPEN_DOCUMENT).apply { + addCategory(Intent.CATEGORY_OPENABLE) + type = "*/*" + } + filePickerLauncher.launch(intent) + } + + private fun handleSelectedFile(uri: Uri) { + selectedFileUri = uri + + // Get file name, size and mime type + requireContext().contentResolver.query(uri, null, null, null, null)?.use { cursor -> + val nameIndex = cursor.getColumnIndex(OpenableColumns.DISPLAY_NAME) + val sizeIndex = cursor.getColumnIndex(OpenableColumns.SIZE) + cursor.moveToFirst() + selectedFileName = if (nameIndex >= 0) cursor.getString(nameIndex) else "file" + selectedFileSize = if (sizeIndex >= 0) cursor.getLong(sizeIndex) else 0 + } + + selectedFileMimeType = requireContext().contentResolver.getType(uri) ?: "application/octet-stream" + + // Show the attachment box with icon, size, and filename field + attachmentBox.visibility = View.VISIBLE + attachmentBoxIcon.setImageResource(mimeTypeToIconResource(selectedFileMimeType)) + attachmentBoxSize.text = formatBytes(selectedFileSize) + attachmentBoxFilenameText.setText(selectedFileName) + + attachmentBoxFilenameText.requestFocus() + showKeyboard(attachmentBoxFilenameText) + } + + override fun onStart() { + super.onStart() + dialog?.window?.apply { + setLayout( + ViewGroup.LayoutParams.MATCH_PARENT, + ViewGroup.LayoutParams.MATCH_PARENT + ) + } + } + + override fun onResume() { + super.onResume() + // Show keyboard after the dialog is fully visible + messageText.postDelayed({ + messageText.requestFocus() + val imm = requireContext().getSystemService(Context.INPUT_METHOD_SERVICE) as? InputMethodManager + imm?.showSoftInput(messageText, InputMethodManager.SHOW_IMPLICIT) + }, 200) + } + + private fun validateInput() { + if (!this::publishMenuItem.isInitialized) return + publishMenuItem.isEnabled = true + } + + private fun onSendClick() { + val title = if (chipTitle.isChecked) titleText.text.toString() else "" + val message = messageText.text.toString() + val markdown = chipMarkdown.isChecked + val priority = if (chipPriority.isChecked) selectedPriority else 3 // Default priority if not shown + val tagsString = if (chipTags.isChecked) tagsText.text.toString() else "" + val tags = if (tagsString.isNotEmpty()) { + tagsString.split(",").map { it.trim() }.filter { it.isNotEmpty() } + } else { + emptyList() + } + + // Optional fields + val clickUrl = if (chipClickUrl.isChecked) clickUrlText.text.toString() else "" + val email = if (chipEmail.isChecked) emailText.text.toString() else "" + val delay = if (chipDelay.isChecked) delayText.text.toString() else "" + val attachUrl = if (chipAttachUrl.isChecked) attachUrlText.text.toString() else "" + val attachFilename = if (chipAttachUrl.isChecked) attachFilenameText.text.toString() else "" + val phoneCall = if (chipPhoneCall.isChecked) phoneCallText.text.toString() else "" + + // Show progress UI + val hasFileAttachment = chipAttachFile.isChecked && selectedFileUri != null + if (hasFileAttachment) { + uploadProgress.visibility = View.VISIBLE + uploadProgress.progress = 0 + uploadProgressText.visibility = View.VISIBLE + uploadProgressText.text = getString(R.string.publish_dialog_uploading, "0%", "0 B", formatBytes(selectedFileSize)) + } + errorText.visibility = View.GONE + errorImage.visibility = View.GONE + enableView(false) + + // Kick off HTTP request + publishing = true + job = lifecycleScope.launch(Dispatchers.IO) { + try { + val user = repository.getUser(baseUrl) + val body = if (hasFileAttachment) createFileRequestBody() else null + val filename = if (hasFileAttachment) attachmentBoxFilenameText.text.toString() else attachFilename + + api.publish( + baseUrl = baseUrl, + topic = topic, + user = user, + message = message, + title = title, + priority = priority, + tags = tags, + delay = delay, + body = body, + filename = filename, + click = clickUrl, + attach = if (hasFileAttachment) "" else attachUrl, + email = email, + call = phoneCall, + markdown = markdown, + onCancelAvailable = { cancel -> this@PublishFragment.cancelFn = cancel } + ) + + withContext(Dispatchers.Main) { + if (!isAdded) return@withContext + publishing = false + cancelFn = null + Toast.makeText(requireContext(), R.string.publish_dialog_message_published, Toast.LENGTH_SHORT).show() + publishListener?.onPublished() + dismiss() + } + } catch (e: Exception) { + Log.w(TAG, "Failed to publish message", e) + withContext(Dispatchers.Main) { + if (!isAdded) return@withContext + publishing = false + cancelFn = null + uploadProgress.visibility = View.GONE + uploadProgressText.visibility = View.GONE + + // Don't show error if cancelled (coroutine or OkHttp call) + if (e is kotlinx.coroutines.CancellationException || + (e is java.io.IOException && e.message?.contains("Canceled") == true)) { + enableView(true) + return@withContext + } + + val errorMessage = when (e) { + is ApiService.UnauthorizedException -> { + if (e.user != null) { + getString(R.string.detail_test_message_error_unauthorized_user, e.user.username) + } else { + 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) + } + } + errorText.text = errorMessage + errorText.visibility = View.VISIBLE + errorImage.visibility = View.VISIBLE + enableView(true) + } + } + } + } + + private fun cancel() { + // Cancel both the HTTP request and the coroutine job + cancelFn?.invoke() + job?.cancel() + cancelFn = null + publishing = false + uploadProgress.visibility = View.GONE + uploadProgressText.visibility = View.GONE + enableView(true) + if (isAdded) { + Toast.makeText(requireContext(), R.string.publish_dialog_upload_cancelled, Toast.LENGTH_SHORT).show() + } + } + + private fun enableView(enable: Boolean) { + titleText.isEnabled = enable + messageText.isEnabled = enable + tagsText.isEnabled = enable + priorityDropdown.isEnabled = enable + + // Chips + chipMarkdown.isEnabled = enable + chipTitle.isEnabled = enable + chipTags.isEnabled = enable + chipPriority.isEnabled = enable + chipClickUrl.isEnabled = enable + chipEmail.isEnabled = enable + chipDelay.isEnabled = enable + chipAttachUrl.isEnabled = enable + chipAttachFile.isEnabled = enable + chipPhoneCall.isEnabled = enable + + // Optional fields + clickUrlText.isEnabled = enable + emailText.isEnabled = enable + delayText.isEnabled = enable + attachUrlText.isEnabled = enable + attachFilenameText.isEnabled = enable + attachmentBoxFilenameText.isEnabled = enable + phoneCallText.isEnabled = enable + + publishMenuItem.isEnabled = enable + } + + companion object { + const val TAG = "NtfyPublishFragment" + private const val ARG_BASE_URL = "baseUrl" + private const val ARG_TOPIC = "topic" + private const val ARG_MESSAGE = "message" + private const val ARG_DISPLAY_NAME = "displayName" + + fun newInstance(baseUrl: String, topic: String, displayName: String, message: String = ""): PublishFragment { + val fragment = PublishFragment() + fragment.arguments = Bundle().apply { + putString(ARG_BASE_URL, baseUrl) + putString(ARG_TOPIC, topic) + putString(ARG_DISPLAY_NAME, displayName) + putString(ARG_MESSAGE, message) + } + return fragment + } + } +} diff --git a/app/src/main/java/io/heckel/ntfy/ui/SettingsActivity.kt b/app/src/main/java/io/heckel/ntfy/ui/SettingsActivity.kt index d69298f2..7dddf817 100644 --- a/app/src/main/java/io/heckel/ntfy/ui/SettingsActivity.kt +++ b/app/src/main/java/io/heckel/ntfy/ui/SettingsActivity.kt @@ -4,7 +4,6 @@ import android.Manifest import android.app.AlarmManager import android.content.ClipData import android.content.ClipboardManager -import android.content.Context import android.content.Intent import android.content.pm.PackageManager import android.os.Build @@ -15,11 +14,13 @@ import android.text.TextUtils import android.view.View import android.widget.Button import android.widget.Toast +import androidx.activity.enableEdgeToEdge import androidx.activity.result.contract.ActivityResultContracts import androidx.annotation.Keep import androidx.appcompat.app.AppCompatActivity import androidx.appcompat.app.AppCompatDelegate import androidx.core.app.ActivityCompat +import androidx.core.os.LocaleListCompat import androidx.core.content.ContextCompat import androidx.core.view.WindowInsetsControllerCompat import androidx.fragment.app.DialogFragment @@ -60,6 +61,7 @@ class SettingsActivity : AppCompatActivity(), PreferenceFragmentCompat.OnPrefere private lateinit var serviceManager: SubscriberServiceManager override fun onCreate(savedInstanceState: Bundle?) { + enableEdgeToEdge() super.onCreate(savedInstanceState) setContentView(R.layout.activity_settings) @@ -85,8 +87,7 @@ class SettingsActivity : AppCompatActivity(), PreferenceFragmentCompat.OnPrefere toolbar.overflowIcon?.setTint(toolbarTextColor) setSupportActionBar(toolbar) - // Set system status bar color and appearance - window.statusBarColor = statusBarColor + // Set system status bar appearance WindowInsetsControllerCompat(window, window.decorView).isAppearanceLightStatusBars = Colors.shouldUseLightStatusBar(dynamicColors, darkMode) @@ -261,14 +262,11 @@ class SettingsActivity : AppCompatActivity(), PreferenceFragmentCompat.OnPrefere // Channel settings val channelPrefsPrefId = context?.getString(R.string.settings_notifications_channel_prefs_key) ?: return val channelPrefs: Preference? = findPreference(channelPrefsPrefId) - channelPrefs?.isVisible = Build.VERSION.SDK_INT >= Build.VERSION_CODES.O channelPrefs?.preferenceDataStore = object : PreferenceDataStore() { } // Dummy store to protect from accidentally overwriting channelPrefs?.onPreferenceClickListener = OnPreferenceClickListener { - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { - startActivity(Intent(Settings.ACTION_APP_NOTIFICATION_SETTINGS).apply { - putExtra(Settings.EXTRA_APP_PACKAGE, BuildConfig.APPLICATION_ID) - }) - } + startActivity(Intent(Settings.ACTION_APP_NOTIFICATION_SETTINGS).apply { + putExtra(Settings.EXTRA_APP_PACKAGE, BuildConfig.APPLICATION_ID) + }) false } @@ -353,6 +351,84 @@ class SettingsActivity : AppCompatActivity(), PreferenceFragmentCompat.OnPrefere } } + // Language + val languagePrefId = context?.getString(R.string.settings_general_language_key) ?: return + val language: ListPreference? = findPreference(languagePrefId) + if (language != null) { + // We only list languages that have > 80% of strings translated. + // + // Please use Hosted Weblate (https://hosted.weblate.org/projects/ntfy/android/) + // to help translate other languages. + // + // IMPORTANT: If a language is added here, also add it to the locales_config.xml file. + + val supportedLocales = listOf( + "" to getString(R.string.settings_general_language_system_default), + "en" to "English", + "bg" to "Български", + "ca" to "Català", + "cs" to "Čeština", + "de" to "Deutsch", + "es" to "Español", + "et" to "Eesti", + "fi" to "Suomi", + "fr" to "Français", + "gl" to "Galego", + "in" to "Bahasa Indonesia", + "it" to "Italiano", + "iw" to "עברית", + "ja" to "日本語", + "ko" to "한국어", + "nb-NO" to "Norsk bokmål", + "nl" to "Nederlands", + "pl" to "Polski", + "pt" to "Português", + "pt-BR" to "Português (Brasil)", + "ro" to "Română", + "ru" to "Русский", + "sk" to "Slovenčina", + "sv" to "Svenska", + "ta" to "தமிழ்", + "tr" to "Türkçe", + "uk" to "Українська", + "uz" to "Oʻzbekcha", + "vi" to "Tiếng Việt", + "zh-CN" to "简体中文", + "zh-TW" to "繁體中文" + ) + // Set title with 3 random flags to help users find this preference + val flags = listOf("🇧🇬", "🇨🇿", "🇩🇪", "🇪🇸", "🇪🇪", "🇫🇮", "🇫🇷", "🇮🇩", "🇮🇱", "🇮🇳", "🇮🇹", "🇯🇵", "🇰🇷", "🇳🇱", "🇳🇴", "🇵🇱", "🇵🇹", "🇧🇷", "🇷🇴", "🇷🇺", "🇸🇪", "🇸🇰", "🇹🇷", "🇹🇼", "🇺🇦", "🇺🇿", "🇻🇳", "🇨🇳") + val randomFlags = flags.shuffled().take(3).joinToString(" ") + language.title = "${getString(R.string.settings_general_language_title)} $randomFlags" + language.entries = supportedLocales.map { it.second }.toTypedArray() + language.entryValues = supportedLocales.map { it.first }.toTypedArray() + + // Get current locale + val currentLocales = AppCompatDelegate.getApplicationLocales() + val currentLocaleTag = if (currentLocales.isEmpty) "" else currentLocales.toLanguageTags() + language.value = currentLocaleTag + + language.setOnPreferenceChangeListener { _, newValue -> + val localeTag = newValue as String + if (localeTag.isEmpty()) { + AppCompatDelegate.setApplicationLocales(LocaleListCompat.getEmptyLocaleList()) + } else { + AppCompatDelegate.setApplicationLocales(LocaleListCompat.forLanguageTags(localeTag)) + } + true + } + + language.summaryProvider = Preference.SummaryProvider { pref -> + val currentLocalesForSummary = AppCompatDelegate.getApplicationLocales() + if (currentLocalesForSummary.isEmpty) { + getString(R.string.settings_general_language_summary_system) + } else { + val locale = currentLocalesForSummary[0] + locale?.getDisplayName(locale)?.replaceFirstChar { it.uppercase() } ?: pref.entry?.toString() ?: "" + } + } + } + // Dynamic colors if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { val dynamicColorsEnabledPrefId = context?.getString(R.string.settings_general_dynamic_colors_key) ?: return @@ -385,6 +461,26 @@ class SettingsActivity : AppCompatActivity(), PreferenceFragmentCompat.OnPrefere dynamicColorsEnabled?.isVisible = true } + // Message bar enabled + val messageBarEnabledPrefId = context?.getString(R.string.settings_general_message_bar_key) ?: return + val messageBarEnabled: SwitchPreferenceCompat? = findPreference(messageBarEnabledPrefId) + messageBarEnabled?.isChecked = repository.getMessageBarEnabled() + messageBarEnabled?.preferenceDataStore = object : PreferenceDataStore() { + override fun putBoolean(key: String?, value: Boolean) { + repository.setMessageBarEnabled(value) + } + override fun getBoolean(key: String?, defValue: Boolean): Boolean { + return repository.getMessageBarEnabled() + } + } + messageBarEnabled?.summaryProvider = Preference.SummaryProvider { pref -> + if (pref.isChecked) { + getString(R.string.settings_general_message_bar_summary_enabled) + } else { + getString(R.string.settings_general_message_bar_summary_disabled) + } + } + // Default Base URL val appBaseUrl = getString(R.string.app_base_url) val defaultBaseUrlPrefId = context?.getString(R.string.settings_general_default_base_url_key) ?: return @@ -627,7 +723,7 @@ class SettingsActivity : AppCompatActivity(), PreferenceFragmentCompat.OnPrefere versionPref?.summary = version versionPref?.onPreferenceClickListener = OnPreferenceClickListener { val context = context ?: return@OnPreferenceClickListener false - val clipboard = context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager + val clipboard = context.getSystemService(CLIPBOARD_SERVICE) as ClipboardManager val clip = ClipData.newPlainText("ntfy version", version) clipboard.setPrimaryClip(clip) Toast @@ -655,7 +751,7 @@ class SettingsActivity : AppCompatActivity(), PreferenceFragmentCompat.OnPrefere val context = context ?: return@launch val log = Log.getFormatted(context, scrub = scrub) requireActivity().runOnUiThread { - val clipboard = context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager + val clipboard = context.getSystemService(CLIPBOARD_SERVICE) as ClipboardManager val clip = ClipData.newPlainText("ntfy logs", log) clipboard.setPrimaryClip(clip) if (scrub) { @@ -697,12 +793,12 @@ class SettingsActivity : AppCompatActivity(), PreferenceFragmentCompat.OnPrefere if (!response.isSuccessful) { throw Exception("Unexpected response ${response.code}") } - val body = response.body?.string()?.trim() - if (body.isNullOrEmpty()) throw Exception("Return body is empty") + val body = response.body.string().trim() + if (body.isEmpty()) throw Exception("Return body is empty") Log.d(TAG, "Logs uploaded successfully: $body") - val resp = gson.fromJson(body.toString(), NopasteResponse::class.java) + val resp = gson.fromJson(body, NopasteResponse::class.java) requireActivity().runOnUiThread { - val clipboard = context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager + val clipboard = context.getSystemService(CLIPBOARD_SERVICE) as ClipboardManager val clip = ClipData.newPlainText("logs URL", resp.url) clipboard.setPrimaryClip(clip) if (scrub) { diff --git a/app/src/main/java/io/heckel/ntfy/ui/ShareActivity.kt b/app/src/main/java/io/heckel/ntfy/ui/ShareActivity.kt index c3d11ea7..ca3d9637 100644 --- a/app/src/main/java/io/heckel/ntfy/ui/ShareActivity.kt +++ b/app/src/main/java/io/heckel/ntfy/ui/ShareActivity.kt @@ -8,6 +8,7 @@ import android.text.Editable import android.text.TextWatcher import android.view.* import android.widget.* +import androidx.activity.enableEdgeToEdge import androidx.appcompat.app.AppCompatActivity import androidx.core.view.WindowInsetsControllerCompat import androidx.lifecycle.lifecycleScope @@ -51,6 +52,7 @@ class ShareActivity : AppCompatActivity() { private lateinit var errorImage: ImageView override fun onCreate(savedInstanceState: Bundle?) { + enableEdgeToEdge() super.onCreate(savedInstanceState) setContentView(R.layout.activity_share) @@ -72,8 +74,7 @@ class ShareActivity : AppCompatActivity() { setSupportActionBar(toolbar) title = getString(R.string.share_title) - // Set system status bar color and appearance - window.statusBarColor = statusBarColor + // Set system status bar appearance WindowInsetsControllerCompat(window, window.decorView).isAppearanceLightStatusBars = Colors.shouldUseLightStatusBar(dynamicColors, darkMode) diff --git a/app/src/main/java/io/heckel/ntfy/ui/UserFragment.kt b/app/src/main/java/io/heckel/ntfy/ui/UserFragment.kt index e32b88fc..9c66d076 100644 --- a/app/src/main/java/io/heckel/ntfy/ui/UserFragment.kt +++ b/app/src/main/java/io/heckel/ntfy/ui/UserFragment.kt @@ -102,7 +102,7 @@ class UserFragment : DialogFragment() { if (user != null) { dialog .getButton(AlertDialog.BUTTON_NEUTRAL) - .dangerButton(requireContext()) + .dangerButton() } // Validate input when typing diff --git a/app/src/main/java/io/heckel/ntfy/util/Emoji.java b/app/src/main/java/io/heckel/ntfy/util/Emoji.java index bda8afc3..12960ea9 100644 --- a/app/src/main/java/io/heckel/ntfy/util/Emoji.java +++ b/app/src/main/java/io/heckel/ntfy/util/Emoji.java @@ -1,6 +1,6 @@ package io.heckel.ntfy.util; -import java.io.UnsupportedEncodingException; +import java.nio.charset.StandardCharsets; import java.util.Collections; import java.util.List; @@ -16,11 +16,7 @@ public class Emoji { protected Emoji(List aliases, byte... bytes) { this.aliases = Collections.unmodifiableList(aliases); - try { - this.unicode = new String(bytes, "UTF-8"); - } catch (UnsupportedEncodingException e) { - throw new RuntimeException(e); - } + this.unicode = new String(bytes, StandardCharsets.UTF_8); } public List getAliases() { diff --git a/app/src/main/java/io/heckel/ntfy/util/EmojiLoader.java b/app/src/main/java/io/heckel/ntfy/util/EmojiLoader.java index f14a5cf6..12712634 100644 --- a/app/src/main/java/io/heckel/ntfy/util/EmojiLoader.java +++ b/app/src/main/java/io/heckel/ntfy/util/EmojiLoader.java @@ -5,6 +5,7 @@ import org.json.JSONException; import org.json.JSONObject; import java.io.*; +import java.nio.charset.StandardCharsets; import java.util.ArrayList; import java.util.List; @@ -37,7 +38,7 @@ public class EmojiLoader { InputStream stream ) throws IOException { StringBuilder sb = new StringBuilder(); - InputStreamReader isr = new InputStreamReader(stream, "UTF-8"); + InputStreamReader isr = new InputStreamReader(stream, StandardCharsets.UTF_8); BufferedReader br = new BufferedReader(isr); String read; while((read = br.readLine()) != null) { @@ -49,12 +50,12 @@ public class EmojiLoader { protected static Emoji buildEmojiFromJSON( JSONObject json - ) throws UnsupportedEncodingException, JSONException { + ) throws JSONException { if (!json.has("emoji")) { return null; } - byte[] bytes = json.getString("emoji").getBytes("UTF-8"); + byte[] bytes = json.getString("emoji").getBytes(StandardCharsets.UTF_8); List aliases = jsonArrayToStringList(json.getJSONArray("aliases")); return new Emoji(aliases, bytes); } diff --git a/app/src/main/java/io/heckel/ntfy/util/ProgressRequestBody.kt b/app/src/main/java/io/heckel/ntfy/util/ProgressRequestBody.kt new file mode 100644 index 00000000..9bd36723 --- /dev/null +++ b/app/src/main/java/io/heckel/ntfy/util/ProgressRequestBody.kt @@ -0,0 +1,37 @@ +package io.heckel.ntfy.util + +import okhttp3.MediaType +import okhttp3.RequestBody +import okio.Buffer +import okio.BufferedSink +import okio.ForwardingSink +import okio.buffer + +/** + * A RequestBody wrapper that reports upload progress. + */ +class ProgressRequestBody( + private val delegate: RequestBody, + private val onProgress: (bytesWritten: Long, totalBytes: Long) -> Unit +) : RequestBody() { + + override fun contentType(): MediaType? = delegate.contentType() + + override fun contentLength(): Long = delegate.contentLength() + + override fun writeTo(sink: BufferedSink) { + val totalBytes = contentLength() + val countingSink = object : ForwardingSink(sink) { + var bytesWritten = 0L + + override fun write(source: Buffer, byteCount: Long) { + super.write(source, byteCount) + bytesWritten += byteCount + onProgress(bytesWritten, totalBytes) + } + } + val bufferedSink = countingSink.buffer() + delegate.writeTo(bufferedSink) + bufferedSink.flush() + } +} diff --git a/app/src/main/java/io/heckel/ntfy/util/Util.kt b/app/src/main/java/io/heckel/ntfy/util/Util.kt index 017830a8..02d512b7 100644 --- a/app/src/main/java/io/heckel/ntfy/util/Util.kt +++ b/app/src/main/java/io/heckel/ntfy/util/Util.kt @@ -11,7 +11,6 @@ import android.graphics.Bitmap import android.graphics.BitmapFactory import android.graphics.drawable.RippleDrawable import android.net.Uri -import android.os.Build import android.os.PowerManager import android.provider.OpenableColumns import android.text.Editable @@ -31,7 +30,6 @@ import io.heckel.ntfy.db.Notification import io.heckel.ntfy.db.Repository import io.heckel.ntfy.db.Subscription import io.heckel.ntfy.msg.MESSAGE_ENCODING_BASE64 -import io.heckel.ntfy.ui.Colors import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.delay @@ -51,6 +49,7 @@ import java.text.StringCharacterIterator import java.util.Date import kotlin.math.abs import kotlin.math.absoluteValue +import androidx.core.net.toUri fun topicUrl(baseUrl: String, topic: String) = "${baseUrl}/${topic}" fun topicUrlUp(baseUrl: String, topic: String) = "${baseUrl}/${topic}?up=1" // UnifiedPush @@ -170,7 +169,7 @@ fun decodeMessage(notification: Notification): String { } else { notification.message } - } catch (e: IllegalArgumentException) { + } catch (_: IllegalArgumentException) { notification.message + "(invalid base64)" } } @@ -182,7 +181,7 @@ fun decodeBytesMessage(notification: Notification): ByteArray { } else { notification.message.toByteArray() } - } catch (e: IllegalArgumentException) { + } catch (_: IllegalArgumentException) { notification.message.toByteArray() } } @@ -232,7 +231,7 @@ fun maybeAppendActionErrors(message: CharSequence, notification: Notification): // Queries the filename of a content URI fun fileName(context: Context, contentUri: String?, fallbackName: String): String { return try { - val info = fileStat(context, Uri.parse(contentUri)) + val info = fileStat(context, contentUri?.toUri()) info.filename } catch (_: Exception) { fallbackName @@ -266,7 +265,7 @@ fun fileStat(context: Context, contentUri: Uri?): FileInfo { fun maybeFileStat(context: Context, contentUri: String?): FileInfo? { return try { - fileStat(context, Uri.parse(contentUri)) // Throws if the file does not exist + fileStat(context, contentUri?.toUri()) // Throws if the file does not exist } catch (_: Exception) { null } @@ -328,7 +327,13 @@ fun mimeTypeToIconResource(mimeType: String?): Int { } fun supportedImage(mimeType: String?): Boolean { - return listOf("image/jpeg", "image/png", "image/gif", "image/webp").contains(mimeType) + return listOf( + "image/jpeg", + "image/jpg", // Technically not a valid MIME type, see https://github.com/binwiederhier/ntfy-android/pull/142 + "image/png", + "image/gif", + "image/webp" + ).contains(mimeType) } // We cannot open .apk files, because we don't have the REQUEST_INSTALL_PACKAGES anymore @@ -342,10 +347,7 @@ fun canOpenAttachment(attachment: Attachment?): Boolean { fun isIgnoringBatteryOptimizations(context: Context): Boolean { val powerManager = context.applicationContext.getSystemService(Context.POWER_SERVICE) as PowerManager val appName = context.applicationContext.packageName - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { - return powerManager.isIgnoringBatteryOptimizations(appName) - } - return true + return powerManager.isIgnoringBatteryOptimizations(appName) } // Returns true if dark mode is on, see https://stackoverflow.com/a/60761189/1440785 @@ -432,7 +434,7 @@ fun Uri.readBitmapFromUri(context: Context): Bitmap { } fun String.readBitmapFromUri(context: Context): Bitmap { - return Uri.parse(this).readBitmapFromUri(context) + return this.toUri().readBitmapFromUri(context) } fun String.readBitmapFromUriOrNull(context: Context): Bitmap? { @@ -491,12 +493,8 @@ fun String.sha256(): String { return digest.fold("") { str, it -> str + "%02x".format(it) } } -fun Button.dangerButton(context: Context) { - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { - setTextAppearance(R.style.DangerText) - } else { - setTextColor(Colors.dangerText(context)) - } +fun Button.dangerButton() { + setTextAppearance(R.style.DangerText) } fun Long.nullIfZero(): Long? { diff --git a/app/src/main/java/io/heckel/ntfy/work/DeleteWorker.kt b/app/src/main/java/io/heckel/ntfy/work/DeleteWorker.kt index 52fe5656..b05fb4b2 100644 --- a/app/src/main/java/io/heckel/ntfy/work/DeleteWorker.kt +++ b/app/src/main/java/io/heckel/ntfy/work/DeleteWorker.kt @@ -1,7 +1,6 @@ package io.heckel.ntfy.work import android.content.Context -import android.net.Uri import androidx.core.content.FileProvider import androidx.work.CoroutineWorker import androidx.work.WorkerParameters @@ -15,6 +14,7 @@ import io.heckel.ntfy.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. @@ -59,7 +59,7 @@ class DeleteWorker(ctx: Context, params: WorkerParameters) : CoroutineWorker(ctx notifications.forEach { notification -> try { val attachment = notification.attachment ?: return - val contentUri = Uri.parse(attachment.contentUri ?: return) + val contentUri = (attachment.contentUri ?: return).toUri() Log.d(TAG, "Deleting attachment for notification ${notification.id}: ${attachment.contentUri} (${attachment.name})") val deleted = resolver.delete(contentUri, null, null) > 0 if (!deleted) { diff --git a/app/src/main/res/color/chip_background_state.xml b/app/src/main/res/color/chip_background_state.xml new file mode 100644 index 00000000..8657332d --- /dev/null +++ b/app/src/main/res/color/chip_background_state.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/app/src/main/res/drawable/ic_create_white_24dp.xml b/app/src/main/res/drawable/ic_create_white_24dp.xml new file mode 100644 index 00000000..21ef5658 --- /dev/null +++ b/app/src/main/res/drawable/ic_create_white_24dp.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_expand_less_gray_24dp.xml b/app/src/main/res/drawable/ic_expand_less_gray_24dp.xml new file mode 100644 index 00000000..be97faaf --- /dev/null +++ b/app/src/main/res/drawable/ic_expand_less_gray_24dp.xml @@ -0,0 +1,10 @@ + + + + diff --git a/app/src/main/res/drawable/ic_priority_3_24dp.xml b/app/src/main/res/drawable/ic_priority_3_24dp.xml new file mode 100644 index 00000000..e0787be3 --- /dev/null +++ b/app/src/main/res/drawable/ic_priority_3_24dp.xml @@ -0,0 +1,13 @@ + + + + + diff --git a/app/src/main/res/drawable/ic_send_gray_24dp.xml b/app/src/main/res/drawable/ic_send_gray_24dp.xml new file mode 100644 index 00000000..64642821 --- /dev/null +++ b/app/src/main/res/drawable/ic_send_gray_24dp.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/layout/activity_detail.xml b/app/src/main/res/layout/activity_detail.xml index ab6327a2..ba0f96f0 100644 --- a/app/src/main/res/layout/activity_detail.xml +++ b/app/src/main/res/layout/activity_detail.xml @@ -18,7 +18,6 @@ android:id="@+id/detail_content_layout" android:layout_width="match_parent" android:layout_height="match_parent" - android:layout_marginBottom="16dp" android:background="@color/detail_activity_background" app:layout_behavior="@string/appbar_scrolling_view_behavior"> @@ -26,8 +25,12 @@ style="@style/CardViewBackground" android:id="@+id/detail_notification_list_container" android:layout_width="match_parent" - android:layout_height="match_parent" - android:visibility="gone"> + android:layout_height="0dp" + android:visibility="gone" + app:layout_constraintTop_toTopOf="parent" + app:layout_constraintBottom_toTopOf="@id/detail_message_bar" + app:layout_constraintStart_toStartOf="parent" + app:layout_constraintEnd_toEndOf="parent"> + + + + diff --git a/app/src/main/res/layout/fragment_add_dialog.xml b/app/src/main/res/layout/fragment_add_dialog.xml index 910d1678..a37daad0 100644 --- a/app/src/main/res/layout/fragment_add_dialog.xml +++ b/app/src/main/res/layout/fragment_add_dialog.xml @@ -21,6 +21,8 @@ android:layout_width="match_parent" android:layout_height="?attr/actionBarSize" android:background="?attr/colorSurface" + android:paddingStart="0dp" + android:paddingEnd="12dp" app:navigationIcon="@drawable/ic_close_white_24dp" app:navigationIconTint="?attr/colorOnSurface" app:title="@string/add_dialog_title" diff --git a/app/src/main/res/layout/fragment_detail_item.xml b/app/src/main/res/layout/fragment_detail_item.xml index 758e03c7..66086b68 100644 --- a/app/src/main/res/layout/fragment_detail_item.xml +++ b/app/src/main/res/layout/fragment_detail_item.xml @@ -178,7 +178,7 @@ app:flow_firstVerticalBias="0" app:flow_firstHorizontalStyle="packed" app:flow_firstVerticalStyle="packed" - app:flow_maxElementsWrap="1" + app:flow_maxElementsWrap="3" android:layout_margin="0dp" android:padding="0dp" app:constraint_referenced_ids="button1,button2,button3"/> diff --git a/app/src/main/res/layout/fragment_main_item.xml b/app/src/main/res/layout/fragment_main_item.xml index 1b999364..264e20b6 100644 --- a/app/src/main/res/layout/fragment_main_item.xml +++ b/app/src/main/res/layout/fragment_main_item.xml @@ -3,7 +3,7 @@ xmlns:app="http://schemas.android.com/apk/res-auto" android:layout_width="match_parent" android:layout_height="wrap_content" - android:background="?android:attr/selectableItemBackground" + android:foreground="?android:attr/selectableItemBackground" android:orientation="horizontal" android:clickable="true" android:focusable="true" android:paddingEnd="18dp" android:paddingStart="18dp"> diff --git a/app/src/main/res/layout/fragment_publish_dialog.xml b/app/src/main/res/layout/fragment_publish_dialog.xml new file mode 100644 index 00000000..5e37c115 --- /dev/null +++ b/app/src/main/res/layout/fragment_publish_dialog.xml @@ -0,0 +1,430 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/layout/item_priority_dropdown.xml b/app/src/main/res/layout/item_priority_dropdown.xml new file mode 100644 index 00000000..ccf18aa1 --- /dev/null +++ b/app/src/main/res/layout/item_priority_dropdown.xml @@ -0,0 +1,28 @@ + + + + + + + + diff --git a/app/src/main/res/layout/view_attachment_box.xml b/app/src/main/res/layout/view_attachment_box.xml new file mode 100644 index 00000000..0e440694 --- /dev/null +++ b/app/src/main/res/layout/view_attachment_box.xml @@ -0,0 +1,53 @@ + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/layout/view_message_bar.xml b/app/src/main/res/layout/view_message_bar.xml new file mode 100644 index 00000000..df1f30ae --- /dev/null +++ b/app/src/main/res/layout/view_message_bar.xml @@ -0,0 +1,83 @@ + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/menu/menu_publish_dialog.xml b/app/src/main/res/menu/menu_publish_dialog.xml new file mode 100644 index 00000000..f18d543e --- /dev/null +++ b/app/src/main/res/menu/menu_publish_dialog.xml @@ -0,0 +1,9 @@ + + + + diff --git a/app/src/main/res/values-bg/strings.xml b/app/src/main/res/values-bg/strings.xml index c0ef6c8a..139d50c8 100644 --- a/app/src/main/res/values-bg/strings.xml +++ b/app/src/main/res/values-bg/strings.xml @@ -349,4 +349,7 @@ Будилници в точно време ntfy може да насрочва будилници в точно определено време. Те са задължителни за повторно свързване на WebSockets във фонов режим. За да оттеглите разрешението докоснете. ntfy не може да насрочва будилници в точно определено време. Те са задължителни за повторно свързване на WebSockets във фонов режим. За да разрешите докоснете. + Динамични цветове + Използване на динамичните системни цветове + Използване на цветовете от темата на ntfy diff --git a/app/src/main/res/values-de/strings.xml b/app/src/main/res/values-de/strings.xml index facaee3f..9aef5947 100644 --- a/app/src/main/res/values-de/strings.xml +++ b/app/src/main/res/values-de/strings.xml @@ -349,17 +349,12 @@ Genaue Alarme ntfy kann genaue Alarme planen. Um WebSockets im Hintergrund wieder zu verbinden, sind genaue Alarme erforderlich. Hier tippen, um die Berechtigung zu widerrufen. ntfy kann keine genauen Alarme planen. Um WebSockets im Hintergrund wieder zu verbinden, sind genaue Alarme erforderlich. Tippe hier, um die Berechtigung zu erteilen. - - - BenutzerdefinierteHeader Benutzerdefinierte Header Benutzerdefinierte HTTP-Header pro Server hinzufügen Benutzerdefinierte Header Header hinzufügen Header für einen Server hinzufügen Header werden mit jeder HTTP-Anfrage an diesen Server gesendet - - Header hinzufügen Neuen benutzerdefinierten HTTP-Header hinzufügen Neuen Header hinzufügen @@ -377,4 +372,7 @@ Einen benutzerdefinierten HTTP-Header hinzufügen, der mit jeder Anfrage an den angegebenen Server gesendet wird. Den Header-Namen/Wert bearbeiten oder löschen. Service-URL + Dynamische Farben + Verwendung der dynamischen Systemfarben + Verwendung der ntfy-Themenfarben diff --git a/app/src/main/res/values-et/strings.xml b/app/src/main/res/values-et/strings.xml index b452d70d..ea367b56 100644 --- a/app/src/main/res/values-et/strings.xml +++ b/app/src/main/res/values-et/strings.xml @@ -339,4 +339,7 @@ Helimärguanded, „Ära sega“ olekuga mittearvestamine, jne. Jätka pidevate märguannetega Anna märku vaid üks kord + Kasuta dünaamilist värvivalikut + Kasutan dünaamilist süsteemi värvide valikut + Kasutan ntfy kujunduste valikut diff --git a/app/src/main/res/values-fa/strings.xml b/app/src/main/res/values-fa/strings.xml index 5449bf84..ee6f09ae 100644 --- a/app/src/main/res/values-fa/strings.xml +++ b/app/src/main/res/values-fa/strings.xml @@ -51,4 +51,50 @@ از سرور دیگری استفاده کن برای اشتراک در موضوعات از سرورهای دیگر، نشانی‌های سرویس را در زیر وارد کنید. با فشردن + یک موضوع ایجاد کرده یا به آن مشترک شوید. از این پس شما هنگام ارسال توسط PUT یا POST بر روی دستگاه خود اطلاعیه دریافت می کنید. + رد کردن + رد کردن + لغو + اشتراک + بازگشت + نام کاربری + گذرواژه + لغو + لغو + برگردان + حذف شده + لغو اشتراک + رونوشت + حذف + لغو + هم‌رسانی + هم‌رسانی + لغو + ذخیره + گشودن + مرور + بارگیری + لغو + تنظیمات + آگاهی‌ها + کم + پیش‌گزیده + زیاد + بیشینه + کمینه + هرگز + عمومی + کاربران + همه‌چیز + پیشرفته + قبول + سوکت‌های وب + درباره + نگارش + ظاهر + درباره + نام کاربری + گذرواژه + لغو + ذخیره + رد کردن diff --git a/app/src/main/res/values-fr/strings.xml b/app/src/main/res/values-fr/strings.xml index 673e78e5..ae1f115e 100644 --- a/app/src/main/res/values-fr/strings.xml +++ b/app/src/main/res/values-fr/strings.xml @@ -31,7 +31,7 @@ Nom d\'utilisateur Échec de la connexion. L\'utilisateur %1$s n\'est pas autorisé. Nouvel utilisateur - Pour envoyer des notifications à ce sujet, veuillez simplement PUT ou POST à l\'URL du sujet. + Pour envoyer des notifications à ce sujet, faites simplement une requête PUT ou POST à l\'URL du sujet. Test : Vous pouvez mettre un titre si vous le voulez. Incapable d\'envoyer le message : %1$s Copié dans le presse-papier @@ -94,7 +94,7 @@ Se connecter Ce sujet requiert que vous soyez connecté. Veuillez entrer un nom d\'utilisateur et un mot de passe. Mot de passe - Example (utilisant curl):
$ curl -d \"Bonjour\" %1$s
+ $ curl -d \"Hi\" %1$s ]]> Annuler Des instructions détaillées sont disponible sur ntfy.sh et dans la documentation. Supprimer toutes les notifications de ce sujet \? @@ -149,7 +149,7 @@ Échec de la restauration : %1$s Mot de passe Mot de passe (pas de changement si laissé vide) - Partager + Partager à Sujets suggérés Message publié Mettre en sourdine les notifications @@ -346,4 +346,10 @@ Demander plus tard Ignorer Autoriser + Alarmes exactes + ntfy peut programmer des alarmes exactes. Les alarmes exactes sont nécessaires pour reconnecter les WebSockets en arrière-plan. Cliquez pour révoquer l’autorisation. + ntfy ne peut pas programmer d’alarmes exactes. Les alarmes exactes sont nécessaires pour reconnecter les WebSockets en arrière-plan. Cliquez pour accorder l’autorisation. + Couleurs dynamiques + Utiliser les couleurs de thème ntfy + Utiliser les couleurs dynamiques du système diff --git a/app/src/main/res/values-gl/strings.xml b/app/src/main/res/values-gl/strings.xml index b35e1473..50d18e87 100644 --- a/app/src/main/res/values-gl/strings.xml +++ b/app/src/main/res/values-gl/strings.xml @@ -349,4 +349,7 @@ Alarmas exactas ntfy pode pogramar alarmas exactas. Estas alarmas requírense para reconectar en segundo plano con WebSockets. Preme para revogar o permiso. ntfy non pode programar alarmas exactas. Estas alarmas requírense para reconectar en segundo plano con WebSockets. Preme para conceder o permiso. + Cores dinámicas + Usando as cores dinámicas do sistema + Usando as cores do decorado ntfy diff --git a/app/src/main/res/values-it/strings.xml b/app/src/main/res/values-it/strings.xml index 3488a85e..a5b64f1d 100644 --- a/app/src/main/res/values-it/strings.xml +++ b/app/src/main/res/values-it/strings.xml @@ -257,7 +257,7 @@ Modalità scura Backup su file Modalità scura attiva. Sei un vampiro? - Backup & Ripristino + Backup e Ripristino Ripristino fallito: %1$s Log copiati negli appunti Informazioni @@ -345,4 +345,7 @@ Allarmi esatti ntfy può programmare allarmi esatti. Gli allarmi esatti sono necessari per riconnettere i WebSocket in sottofondo. Clicca per revocare l\'autorizzazione. ntfy non può pianificare allarmi esatti. Gli allarmi esatti sono necessari per riconnettere i WebSocket in sottofondo. Clicca per concedere l\'autorizzazione. + Colori dinamici + Utilizzo dei colori del sistema dinamico + Utilizzo dei colori del tema di ntfy diff --git a/app/src/main/res/values-ja/strings.xml b/app/src/main/res/values-ja/strings.xml index 30543449..cbc2a966 100644 --- a/app/src/main/res/values-ja/strings.xml +++ b/app/src/main/res/values-ja/strings.xml @@ -349,4 +349,7 @@ 正確なアラーム ntfyは正確なアラームをスケジュールできます。正確なアラームはバックグラウンドでWebSocketを再接続するのに使用されます。クリックして権限を取り消す。 ntfyは正確なアラームをスケジュールできません。WebSocketsをバックグラウンドで再接続するには正確なアラームが必要です。クリックして権限を許可してください。 + ダイナミックカラー + システムのダイナミックカラーを使用する + ntfyのテーマカラーを使用する diff --git a/app/src/main/res/values-night/colors.xml b/app/src/main/res/values-night/colors.xml index 88bf71d7..4c4ada57 100644 --- a/app/src/main/res/values-night/colors.xml +++ b/app/src/main/res/values-night/colors.xml @@ -144,4 +144,8 @@ #1B2023 #121212 + + + #2C2C2C + #4A4A4A diff --git a/app/src/main/res/values-night/themes.xml b/app/src/main/res/values-night/themes.xml index 9d0d8644..071d1b4d 100644 --- a/app/src/main/res/values-night/themes.xml +++ b/app/src/main/res/values-night/themes.xml @@ -8,5 +8,14 @@ - + + + diff --git a/app/src/main/res/values-tr/strings.xml b/app/src/main/res/values-tr/strings.xml index 550042ab..5e92e2de 100644 --- a/app/src/main/res/values-tr/strings.xml +++ b/app/src/main/res/values-tr/strings.xml @@ -347,4 +347,7 @@ Kesin alarmlar ntfy, kesin alarmlar planlayabilir. Kesin alarmlar, WebSocket’lerin arka planda yeniden bağlanması için gereklidir. İzni geri almak için tıklayın. ntfy, kesin alarmlar planlayamaz. Kesin alarmlar, WebSocket’lerin arka planda yeniden bağlanması için gereklidir. İzni vermek için tıklayın. + Değişken renkler + Değişken sistem renkleri kullanılıyor + ntfy tema renkleri kullanılıyor diff --git a/app/src/main/res/values-vi/strings.xml b/app/src/main/res/values-vi/strings.xml index 4b9beb3a..543d350c 100644 --- a/app/src/main/res/values-vi/strings.xml +++ b/app/src/main/res/values-vi/strings.xml @@ -1,27 +1,27 @@ - Ưu tiên thấp + Mức ưu tiên thấp Mật khẩu Tên tài khoản - Chủ đề này cần đăng nhập để truy cập. Vui lòng nhập tên tài khoản và mật khẩu. - Thêm người dùng + Chủ đề cần đăng nhập để truy cập. Vui lòng nhập tên tài khoản và mật khẩu. + Thêm tài khoản Đăng nhập thất bại. Tài khoản %1$s không được cấp quyền. Bạn có thể chỉnh sửa tên tài khoản hoặc mật khẩu của người dùng này, hoặc xóa người dùng này khỏi hệ thống. Lưu Mật khẩu Tên chủ đề, ví dụ: phils_alerts - Xóa người dùng + Xóa tài khoản Hủy Sử dụng máy chủ khác Mật khẩu (không đổi nếu để trống) Theo dõi chủ đề Tên tài khoản - Ưu tiên thấp nhất - Ưu tiên cao nhất + Mức ưu tiên thấp nhất + Mức ưu tiên cao nhất Mặc định Dịch vụ thông báo Mặc định - Ưu tiên cao + Mức ưu tiên cao Chờ thông báo Đã nhận %1$d thông báo Xóa vĩnh viễn @@ -32,11 +32,11 @@ Đăng kí Quay lại Đăng nhập - Yêu cầi đăng nhập - Ví dụ (với curl):

$ curl -d \"Hi\" %1$s
+ Yêu cầu đăng nhập + $ curl -d \"Xin chào\" %1$s-]]> Kết nối không thành công: %1$s Bạn chưa nhận thông báo nào cho chủ đề này. - Để gửi thông báo tới chủ đề này, hãy PUT hoặc POST vào URL của chủ đề. + Để gửi thông báo tới chủ đề này, hãy PUT hoặc POST tới URL của chủ đề. Kết nối đến máy chủ bằng WebSockets là cách được khuyến nghị và có thể cải thiện thời lượng pin, nhưng có thể cần thêm cấu hình trong proxy của bạn. Điều này có thể được bật/tắt trong Cài đặt. Cài đặt Báo lỗi @@ -58,4 +58,288 @@ Huỷ Huỷ Huỷ + Đã đăng kí nhận thông báo thời gian thực + Đã đăng kí một chủ đề nhận thông báo thời gian thực + Đã đăng kí hai chủ đề nhận thông báo thời gian thực + Đã đăng kí ba chủ đề nhận thông báo thời gian thực + Đã đăng kí bốn chủ đề nhận thông báo thời gian thực + Đã đăng kí năm chủ đề nhận thông báo thời gian thực + Đã đăng kí sáu chủ đề nhận thông báo thời gian thực + Đã đăng kí %1$d chủ đề nhận thông báo thời gian thực + Đã đăng kí các chủ đề + Đã đăng kí một chủ đề + Đã đăng kí hai chủ đề + Đã đăng kí ba chủ đề + Đã đăng kí bốn chủ đề + Đã đăng kí năm chủ đề + Đã đăng kí sáu chủ đề + Đã đăng kí %1$d chủ đề + Không có thông báo mới + Không thể cập nhật %1$d chủ đề\n\n%2$s + Không thể cập nhật chủ đề: %1$s + Hiện thông báo + Đã tắt thông báo + Đang kết nối lại … + %1$s (UnifiedPush) + Đăng kí thêm + Bấm nút + để tạo hoặc đăng kí chủ đề. Bạn sẽ nhận thông báo trên các thiết bị khi gửi thông báo bằng PUT hoặc POST. + Chủ đề này được quản lí bởi %1$s thông qua UnifiedPush + Khuyến nghị tắt chế độ tiết kiệm pin để tránh lỗi thông báo. + Bỏ qua + Cho phép + Bỏ qua + Cho phép + Để đảm bảo WebSocket kết nối lại khi chạy nền, hãy cấp quyền Báo thức & Nhắc nhở cho ntfy + Hỏi sau + Bỏ qua + Cho phép + Chủ đề không được bảo vệ bằng mật khẩu, vì vậy hãy đặt tên khó đoán. Sau khi đăng ký, bạn có thể gửi thông báo bằng PUT/POST. + Nhập URL bên dưới để đăng ký các chủ đề từ server khác. + Nhận thông báo thời gian thực trong chế độ ngủ + Đảm bảo thông báo được gửi ngay cả khi thiết bị không hoạt động. + Thông báo thời gian thực luôn được bật cho hosts khác ngoài %1$s. + Chọn URL + Xóa URL + Hướng dẫn cụ thể trên ntfy.sh, và trong tài liệu hướng dẫn. + Xóa tất cả thông báo trong chủ đề này? + Xóa vĩnh viễn + Hủy đăng kí chủ đề và xóa tát cả thông báo đã nhận? + Xóa vĩnh viễn + Thử nghiệm: Bạn có thể đặt title nếu thích. + Thông báo thử nghiệm từ ntfy Android. Mức ưu tiên %1$d. Gửi thêm có thể khác. + Không thể gửi thông báo: %1$s + Không thể gửi thông báo: Gửi ẩn danh không được phép. + Không thể gửi thông báo: Người dùng \"%1$s\" không có quyền. + Không thể gửi thông báo: Tệp đính kèm quá lớn. + Đã lưu vào clipboard + Hiện thông báo thời gian thực + Đã tắt thông báo thời gian thực + Đã đăng kí chủ đề %1$s + Đã xóa thông báo + Hoàn tác + Mở tệp + Xóa tệp + Tải tệp + Hủy tải xuống + Lưu tệp + Sao chép URL + URL đã được sao chép vào clipboard + Sao chép thông báo + Thông báo đã được sao chép vào clipboard + Đã lưu thành \"%1$s\" trong thư mục \"Downloads\" + Không thể mở hoặc tải tệp. Liên kết hết hạn và không tìm thấy tệp. + Không thể mở tệp đính kèm: %1$s + Không thể mở tệp: Tệp đã xóa hoặc không có ứng dụng mở được. + Không thể mở liên kết: %1$s + Không thể cài ứng dụng trực tiếp. Hãy tải qua trình duyệt. Xem issue #531 để biết thêm chi tiết. + Không thể lưu tệp đính kèm: %1$s + Không thể xóa tệp đính kèm: %1$s + Không thể tải tệp đính kèm: %1$s + chưa tải xuống + chưa tải xuống, liên kết hết hạn + chưa tải xuống, hết hạn %1$s + Đã tải %1$d%% + đã xóa + đã xóa, liên kết hết hạn + đã xóa, liên kết hết hạn %1$s + tải xuống thất bại + tải xuống thất bại, liên kết hết hạn + tải xuống thất bại, liên kết hết hạn %1$s + Hiện thông báo + Đã tắt thông báo + Thông báo bị tắt cho đến %1$s + Bật thông báo thời gian thực + Tắt thông báo thời gian thực + Gửi thông báo thử nghiệm + Sao chép địa chỉ chủ đề + Xóa tất cả thông báo + Cài đặt đăng kí + Hủy đăng kí + Sao chép + Xóa + Xóa vĩnh viễn thông báo đã chọn? + Xóa vĩnh viễn + Cài đặt đăng kí + Chia sẻ + Chia sẻ + Xem trước thông báo + Thêm nội dung để chia sẻ + Một ảnh đã được chia sẻ với bạn + Không thể đọc ảnh: %1$s + Một tệp đã được chia sẻ với bạn + Không thể đọc thông tin tệp: %1$s + Chia sẻ tới + Các chủ đề được gợi ý + Đã chia sẻ thành công + Tắt thông báo + Thông báo bật lại + Đã tắt thông báo + Thông báo bị tắt cho đến %1$s + Xem tất cả thông báo + 30 phút + 1 tiếng + 2 tiếng + 8 tiếng + Ngày mai + Cho đến khi bật lại + Mở + Duyệt + Tải + %1$s\nTệp: %2$s + Đang tải %1$s, %2$d%%\n%3$s + %1$s\nTệp: %2$s đã tải xong + %1$s\nTệp: %2$s, tải thất bại + %1$s thất bại: %2$s + Cài đặt + Thông báo + Tắt thông báp + Đang hiển thị tất cả thông báo + Đã tắt thông báo đến khi bật lại + Thông báo bị tắt cho đến %1$s + Mức ưu tiên thấp nhất + Đang hiển thị tất cả thông báo + Hiển thị thông báo nếu mức ưu tiên là %1$d (%2$s) hoặc cao hơn + Hiển thị thông báo nếu mức ưu tiên 5 (cao nhất) + Mọi mức ưu tiên + Mức ưu tiên thấp trở lên + Mức mặc định trở lên + Mức ưu tiên cao trở lên + Chỉ mức ưu tiên cao nhất + thấp + thấp nhất + mặc định + cao + cao nhất + cài đặt kênh + Bỏ qua chế độ Không làm phiền (DND), âm thanh, v.v. + Tải tệp đính kèm + Tự động tải tệp đính kèm + Không tự động tải tệp đính kèm + Tự động tải tệp đính kèm tối đa %1$s + Không tự động tải + Tự động tải + nếu dưới 100kB + nếu dưới 500 kB + nếu dưới 1 MB + nếu dưới 5 MB + nếu dưới 10 MB + nếu dưới 50 MB + Xóa thông báo + Không tự động xóa thông báo + Tự động xóa thông báo sau một ngày + Tự động xóa thông báo sau ba ngày + Tự động xóa thông báo sau một tuần + Tự động xóa thông báo sau một tháng + Tự động xóa thông báo sau ba tháng + Không bao giờ + Sau một ngày + Sau ba ngày + Sau một tuần + Sau một tháng + Sau ba tháng + Cảnh báo mức ưu tiên cao nhất + Cảnh báo liên tục cho thông báo ưu tiên cao nhất cho đén khi tắt + Cảnh báo một lần cho thông báo ưu tiên cao nhất + Tổng quan + Server mặc định + Nhập URL server để dùng làm mặc định khi đăng ký/chia sẻ chủ đề mới. + %1$s (mặc định) + Quản lí tài khoản + Thêm/xóa tài khoản cho các topic được bảo vệ + Tài khoản + Hiện chưa có topic sử dụng + Topic %1$s đang sử dụng + Các topic %1$s đang sử dụng + Thêm tài khoản + Thêm tài khoản mới + Tạo tài khoản cho server mới + Chế độ tối + Mặc định hệ thống + Chế độ sáng + Đã bật chế độ tối. Sợ ánh sáng hả? + Mặc định hệ thống + Chế độ sáng + Chế độ tối + Màu động + Màu động hệ thống + Màu ntfy + Sao lưu & Phục hồi + Sao lưu vào tệp + Xuất cấu hình, thông báo và người dùng + Tất cả + Tất cả, trừ tài khoản + Chỉ cài đặt + Bản sao lưu đã được tạo + Sao lưu thất bại: %1$s + Phục hồi từ tệp + Nhập cấu hình, thông báo và tài khoản + Phục hồi thành công + Phục hồi thất bại: %1$s + Nâng cao + Broadcast thông báo + Ứng dụng có thể nhận thông báo dưới dạng broadcast + Ứng dụng không thể nhận thông báo dưới dạng broadcast + Bật UnifiedPush + ntfy sẽ gửi thông báo thông qua UnifiedPush + ntfy sẽ không gửi thông báo thông qua UnifiedPush + Ghi logs + Ghi logs (tối đa 1.000 đầu mục) vào thiết bị… + Bật ghi log để chia sẻ log để chẩn đoán sự cố sau này. + Sao chép/tải lên logs + Sao chép logs vào clipboard hoặc tải lên nopaste.net (thuộc tác giả ntfy). Hostname và topic có thể ẩn, nhưng thông báo sẽ luôn giữ nguyên. + Sao chép vào clipboard + Sao chép vào clipboard (có che) + Tải lên và sao chép liên kết + Tải lên và sao chép liên kết (có che) + Logs đã được sao chép vào clipboard + Đang tải lên logs … + Log đã được tải lên và URL đã sao chép + Không thể tải lên log: %1$s + Topic/hostname đã đổi thành tên trái cây để có thể chia sẻ log an toàn:\n\n%1$s\n\nMật khẩu đã xoá, không liệt kê ở đây. + Không có chủ đề/hostname nào bị ẩn. Có thể bạn chưa đăng ký chủ đề nào? + Xóa logs + Xóa log cũ và bắt đầu lại + Đã xóa logs + Giao thức kết nối + Sử dụng JSON stream qua HTTP để kết nối server. Phương pháp này đã được kiểm chứng nhưng có thể tốn pin hơn. + Sử dụng WebSockets để kết nối server. Đây là phương pháp được khuyến nghị, nhưng có thể cần tùy chỉnh thêm ở proxy. + JSON stream qua HTTP + Báo thức chính xác + ntfy có thể đặt báo thức chính xác. Báo thức chính xác cần thiết để kết nối lại WebSockets khi chạy nền. Nhấn để thu hồi quyền. + ntfy không thể đặt báo thức chính xác. Báo thức chính xác cần thiết để kết nối lại WebSockets khi chạy nền. Nhấn để cấp quyền. + Phiên bản + Đã lưu vào clipboard + Thông báo thời gian thực + Thông báo được gửi ngay lập tức. Cần foreground service và tốn pin hơn. + Thông báo được gửi qua Firebase. Có thể trễ, nhưng tiết kiệm pin hơn. + Cài đặt thông báo tùy chỉnh + Đang sử dụng cài đặt tùy chỉnh cho chủ đề này + Đang sử dụng cài đặt mặc định (âm thanh, bỏ qua Không làm phiền, v.v.) + Tùy chỉnh cài đặt thông báo + Bỏ qua Không làm phiền (DND), âm thanh, etc. + Cảnh báo liên tục + Cảnh báo một lần + Giao diện + Biểu tượng chủ đề + Chọn biểu tượng hiển thị trong thông báo + Biểu tượng chủ đề (Nhấn để xóa) + Hiển thị biểu tượng trong thông báo của topic này + Không thể lưu biểu tượng: %1$s + Tên hiển thị + Đặt tên hiển thị cho subscription này. Để trống để sử dụng tên mặc định (%1$s). + %1$s (mặc định) + Sử dụng cài đặt chung + đang sử dụng cài đặt chung + URL chủ đề + Đã lưu vào clipboard + Thêm tài khoản + Chỉnh sửa tài khoản + Thêm tài khoản cho server này. Tất cả chủ đề của server sẽ dùng tài khoản này. + URL dịch vụ + WebSockets + About + ntfy %1$s (%2$s) + About + Tags: %1$s + OK
diff --git a/app/src/main/res/values-zh-rCN/strings.xml b/app/src/main/res/values-zh-rCN/strings.xml index cff8a815..c811682e 100644 --- a/app/src/main/res/values-zh-rCN/strings.xml +++ b/app/src/main/res/values-zh-rCN/strings.xml @@ -349,4 +349,7 @@ 精确闹钟 ntfy 可以安排精确闹钟。在后台重连 WebSockets 需要精确闹钟权限。单击撤销该权限。 ntfy 无法安排精确闹钟。在后台重连 WebSockets 需要精确闹钟权限。单击授予该权限。 + 动态颜色 + 使用动态系统颜色 + 使用 ntfy 主题色 diff --git a/app/src/main/res/values-zh-rTW/strings.xml b/app/src/main/res/values-zh-rTW/strings.xml index 316205d8..e145671f 100644 --- a/app/src/main/res/values-zh-rTW/strings.xml +++ b/app/src/main/res/values-zh-rTW/strings.xml @@ -349,4 +349,7 @@ 精準提醒 ntfy 可以排程精準提醒。精準提醒是讓 WebSocket 能在背景重新連線的必要條件。點擊以撤銷此權限。 ntfy 無法排程精準提醒。精準提醒是讓 WebSocket 能在背景重新連線的必要條件。點擊以授予此權限。 + 動態色彩 + 使用系統動態色彩 + 使用 ntfy 主題色彩 diff --git a/app/src/main/res/values/colors.xml b/app/src/main/res/values/colors.xml index 6a78d973..af69e13a 100644 --- a/app/src/main/res/values/colors.xml +++ b/app/src/main/res/values/colors.xml @@ -145,6 +145,10 @@ #338574 #EEEEEE + + #E8E8E8 + #BDBDBD + @android:color/transparent @android:color/transparent diff --git a/app/src/main/res/values/dimens.xml b/app/src/main/res/values/dimens.xml new file mode 100644 index 00000000..3130a95c --- /dev/null +++ b/app/src/main/res/values/dimens.xml @@ -0,0 +1,4 @@ + + + 24dp + diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index dce126a6..17b28d11 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -199,6 +199,58 @@ Subscription settings + + Publish to %1$s + Title + e.g. Someone is at the door + Message + @string/message_bar_hint + Tags + e.g. warning, skull + Priority + @string/channel_notifications_default_name + @string/channel_notifications_min_name + @string/channel_notifications_low_name + @string/channel_notifications_high_name + @string/channel_notifications_max_name + Publish + Cannot publish message: %1$s + Cannot publish message: %1$s (code %2$d) + Message published + Uploading: %1$s (%2$s / %3$s) + Upload cancelled + Title + Tags + Priority + Click URL + Email + Delay + Markdown + Attach by URL + Attach local file + Phone call + Click URL + e.g. https://example.com/alerts/1234 + Email + e.g. phil@example.com + Delay delivery + e.g. 30m, 1h, today 9pm (English only) + Attachment URL + e.g. https://example.com/flowers.jpg + Attachment filename + e.g. lilies.jpg + Phone call + e.g. +1234567890 + For examples and a detailed description of all publish features, please refer to the documentation. + + + Type a message here + Publish message + More options + + + Publish notification + Share Share @@ -309,9 +361,15 @@ Use system default Light mode Dark mode + Language + Using system default + System default Dynamic colors Using the dynamic system colors Using the ntfy theme colors + Show message bar + Message bar shown at bottom of topic view + Publish button shown at bottom of topic view Backup & Restore Back up to file Export config, notifications, and users diff --git a/app/src/main/res/values/themes.xml b/app/src/main/res/values/themes.xml index 470ea305..644daf63 100644 --- a/app/src/main/res/values/themes.xml +++ b/app/src/main/res/values/themes.xml @@ -102,7 +102,7 @@ @@ -111,6 +111,7 @@ false ?attr/colorSurface ?attr/colorSurface + true @style/Animation.App.FullScreenDialog diff --git a/app/src/main/res/values/values.xml b/app/src/main/res/values/values.xml index 77de8393..cea37ce7 100644 --- a/app/src/main/res/values/values.xml +++ b/app/src/main/res/values/values.xml @@ -22,7 +22,9 @@ DefaultBaseURL ManageUsers DarkMode + Language DynamicColors + MessageBarEnabled Backup Restore BroadcastEnabled diff --git a/app/src/main/res/xml/locales_config.xml b/app/src/main/res/xml/locales_config.xml new file mode 100644 index 00000000..bde4af5c --- /dev/null +++ b/app/src/main/res/xml/locales_config.xml @@ -0,0 +1,43 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/xml/main_preferences.xml b/app/src/main/res/xml/main_preferences.xml index c2b82520..1a2f9b35 100644 --- a/app/src/main/res/xml/main_preferences.xml +++ b/app/src/main/res/xml/main_preferences.xml @@ -54,10 +54,17 @@ app:entries="@array/settings_general_dark_mode_entries" app:entryValues="@array/settings_general_dark_mode_values" app:defaultValue="-1"/> + + \(.*\)$'` - if expr "$link" : '/.*' > /dev/null; then - PRG="$link" - else - PRG=`dirname "$PRG"`"/$link" - fi +app_path=$0 + +# Need this for daisy-chained symlinks. +while + APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path + [ -h "$app_path" ] +do + ls=$( ls -ld "$app_path" ) + link=${ls#*' -> '} + case $link in #( + /*) app_path=$link ;; #( + *) app_path=$APP_HOME$link ;; + esac done -SAVED="`pwd`" -cd "`dirname \"$PRG\"`/" >/dev/null -APP_HOME="`pwd -P`" -cd "$SAVED" >/dev/null -APP_NAME="Gradle" -APP_BASE_NAME=`basename "$0"` - -# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. -DEFAULT_JVM_OPTS="" +# This is normally unused +# shellcheck disable=SC2034 +APP_BASE_NAME=${0##*/} +# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036) +APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit # Use the maximum available, or set MAX_FD != -1 to use that value. -MAX_FD="maximum" +MAX_FD=maximum warn () { echo "$*" -} +} >&2 die () { echo echo "$*" echo exit 1 -} +} >&2 # OS specific support (must be 'true' or 'false'). cygwin=false msys=false darwin=false nonstop=false -case "`uname`" in - CYGWIN* ) - cygwin=true - ;; - Darwin* ) - darwin=true - ;; - MINGW* ) - msys=true - ;; - NONSTOP* ) - nonstop=true - ;; +case "$( uname )" in #( + CYGWIN* ) cygwin=true ;; #( + Darwin* ) darwin=true ;; #( + MSYS* | MINGW* ) msys=true ;; #( + NONSTOP* ) nonstop=true ;; esac -CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar + # Determine the Java command to use to start the JVM. if [ -n "$JAVA_HOME" ] ; then if [ -x "$JAVA_HOME/jre/sh/java" ] ; then # IBM's JDK on AIX uses strange locations for the executables - JAVACMD="$JAVA_HOME/jre/sh/java" + JAVACMD=$JAVA_HOME/jre/sh/java else - JAVACMD="$JAVA_HOME/bin/java" + JAVACMD=$JAVA_HOME/bin/java fi if [ ! -x "$JAVACMD" ] ; then die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME @@ -81,92 +131,118 @@ Please set the JAVA_HOME variable in your environment to match the location of your Java installation." fi else - JAVACMD="java" - which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. + JAVACMD=java + if ! command -v java >/dev/null 2>&1 + then + die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. Please set the JAVA_HOME variable in your environment to match the location of your Java installation." + fi fi # Increase the maximum file descriptors if we can. -if [ "$cygwin" = "false" -a "$darwin" = "false" -a "$nonstop" = "false" ] ; then - MAX_FD_LIMIT=`ulimit -H -n` - if [ $? -eq 0 ] ; then - if [ "$MAX_FD" = "maximum" -o "$MAX_FD" = "max" ] ; then - MAX_FD="$MAX_FD_LIMIT" - fi - ulimit -n $MAX_FD - if [ $? -ne 0 ] ; then - warn "Could not set maximum file descriptor limit: $MAX_FD" - fi - else - warn "Could not query maximum file descriptor limit: $MAX_FD_LIMIT" - fi -fi - -# For Darwin, add options to specify how the application appears in the dock -if $darwin; then - GRADLE_OPTS="$GRADLE_OPTS \"-Xdock:name=$APP_NAME\" \"-Xdock:icon=$APP_HOME/media/gradle.icns\"" -fi - -# For Cygwin, switch paths to Windows format before running java -if $cygwin ; then - APP_HOME=`cygpath --path --mixed "$APP_HOME"` - CLASSPATH=`cygpath --path --mixed "$CLASSPATH"` - JAVACMD=`cygpath --unix "$JAVACMD"` - - # We build the pattern for arguments to be converted via cygpath - ROOTDIRSRAW=`find -L / -maxdepth 1 -mindepth 1 -type d 2>/dev/null` - SEP="" - for dir in $ROOTDIRSRAW ; do - ROOTDIRS="$ROOTDIRS$SEP$dir" - SEP="|" - done - OURCYGPATTERN="(^($ROOTDIRS))" - # Add a user-defined pattern to the cygpath arguments - if [ "$GRADLE_CYGPATTERN" != "" ] ; then - OURCYGPATTERN="$OURCYGPATTERN|($GRADLE_CYGPATTERN)" - fi - # Now convert the arguments - kludge to limit ourselves to /bin/sh - i=0 - for arg in "$@" ; do - CHECK=`echo "$arg"|egrep -c "$OURCYGPATTERN" -` - CHECK2=`echo "$arg"|egrep -c "^-"` ### Determine if an option - - if [ $CHECK -ne 0 ] && [ $CHECK2 -eq 0 ] ; then ### Added a condition - eval `echo args$i`=`cygpath --path --ignore --mixed "$arg"` - else - eval `echo args$i`="\"$arg\"" - fi - i=$((i+1)) - done - case $i in - (0) set -- ;; - (1) set -- "$args0" ;; - (2) set -- "$args0" "$args1" ;; - (3) set -- "$args0" "$args1" "$args2" ;; - (4) set -- "$args0" "$args1" "$args2" "$args3" ;; - (5) set -- "$args0" "$args1" "$args2" "$args3" "$args4" ;; - (6) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" ;; - (7) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" ;; - (8) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" ;; - (9) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" "$args8" ;; +if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then + case $MAX_FD in #( + max*) + # In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + MAX_FD=$( ulimit -H -n ) || + warn "Could not query maximum file descriptor limit" + esac + case $MAX_FD in #( + '' | soft) :;; #( + *) + # In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + ulimit -n "$MAX_FD" || + warn "Could not set maximum file descriptor limit to $MAX_FD" esac fi -# Escape application args -save () { - for i do printf %s\\n "$i" | sed "s/'/'\\\\''/g;1s/^/'/;\$s/\$/' \\\\/" ; done - echo " " -} -APP_ARGS=$(save "$@") +# Collect all arguments for the java command, stacking in reverse order: +# * args from the command line +# * the main class name +# * -classpath +# * -D...appname settings +# * --module-path (only if needed) +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables. -# Collect all arguments for the java command, following the shell quoting and substitution rules -eval set -- $DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS "\"-Dorg.gradle.appname=$APP_BASE_NAME\"" -classpath "\"$CLASSPATH\"" org.gradle.wrapper.GradleWrapperMain "$APP_ARGS" +# For Cygwin or MSYS, switch paths to Windows format before running java +if "$cygwin" || "$msys" ; then + APP_HOME=$( cygpath --path --mixed "$APP_HOME" ) -# by default we should be in the correct project dir, but when run from Finder on Mac, the cwd is wrong -if [ "$(uname)" = "Darwin" ] && [ "$HOME" = "$PWD" ]; then - cd "$(dirname "$0")" + JAVACMD=$( cygpath --unix "$JAVACMD" ) + + # Now convert the arguments - kludge to limit ourselves to /bin/sh + for arg do + if + case $arg in #( + -*) false ;; # don't mess with options #( + /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath + [ -e "$t" ] ;; #( + *) false ;; + esac + then + arg=$( cygpath --path --ignore --mixed "$arg" ) + fi + # Roll the args list around exactly as many times as the number of + # args, so each arg winds up back in the position where it started, but + # possibly modified. + # + # NB: a `for` loop captures its iteration list before it begins, so + # changing the positional parameters here affects neither the number of + # iterations, nor the values presented in `arg`. + shift # remove old arg + set -- "$@" "$arg" # push replacement arg + done fi + +# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' + +# Collect all arguments for the java command: +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments, +# and any embedded shellness will be escaped. +# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be +# treated as '${Hostname}' itself on the command line. + +set -- \ + "-Dorg.gradle.appname=$APP_BASE_NAME" \ + -jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \ + "$@" + +# Stop when "xargs" is not available. +if ! command -v xargs >/dev/null 2>&1 +then + die "xargs is not available" +fi + +# Use "xargs" to parse quoted args. +# +# With -n1 it outputs one arg per line, with the quotes and backslashes removed. +# +# In Bash we could simply go: +# +# readarray ARGS < <( xargs -n1 <<<"$var" ) && +# set -- "${ARGS[@]}" "$@" +# +# but POSIX shell has neither arrays nor command substitution, so instead we +# post-process each arg (as a line of input to sed) to backslash-escape any +# character that might be a shell metacharacter, then use eval to reverse +# that process (while maintaining the separation between arguments), and wrap +# the whole thing up as a single "set" statement. +# +# This will of course break if any of these variables contains a newline or +# an unmatched quote. +# + +eval "set -- $( + printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" | + xargs -n1 | + sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' | + tr '\n' ' ' + )" '"$@"' + exec "$JAVACMD" "$@" diff --git a/gradlew.bat b/gradlew.bat index e95643d6..e509b2dd 100644 --- a/gradlew.bat +++ b/gradlew.bat @@ -1,4 +1,22 @@ -@if "%DEBUG%" == "" @echo off +@rem +@rem Copyright 2015 the original author or authors. +@rem +@rem Licensed under the Apache License, Version 2.0 (the "License"); +@rem you may not use this file except in compliance with the License. +@rem You may obtain a copy of the License at +@rem +@rem https://www.apache.org/licenses/LICENSE-2.0 +@rem +@rem Unless required by applicable law or agreed to in writing, software +@rem distributed under the License is distributed on an "AS IS" BASIS, +@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +@rem See the License for the specific language governing permissions and +@rem limitations under the License. +@rem +@rem SPDX-License-Identifier: Apache-2.0 +@rem + +@if "%DEBUG%"=="" @echo off @rem ########################################################################## @rem @rem Gradle startup script for Windows @@ -9,25 +27,29 @@ if "%OS%"=="Windows_NT" setlocal set DIRNAME=%~dp0 -if "%DIRNAME%" == "" set DIRNAME=. +if "%DIRNAME%"=="" set DIRNAME=. +@rem This is normally unused set APP_BASE_NAME=%~n0 set APP_HOME=%DIRNAME% +@rem Resolve any "." and ".." in APP_HOME to make it shorter. +for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi + @rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. -set DEFAULT_JVM_OPTS= +set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" @rem Find java.exe if defined JAVA_HOME goto findJavaFromJavaHome set JAVA_EXE=java.exe %JAVA_EXE% -version >NUL 2>&1 -if "%ERRORLEVEL%" == "0" goto init +if %ERRORLEVEL% equ 0 goto execute -echo. -echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. -echo. -echo Please set the JAVA_HOME variable in your environment to match the -echo location of your Java installation. +echo. 1>&2 +echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 goto fail @@ -35,48 +57,35 @@ goto fail set JAVA_HOME=%JAVA_HOME:"=% set JAVA_EXE=%JAVA_HOME%/bin/java.exe -if exist "%JAVA_EXE%" goto init +if exist "%JAVA_EXE%" goto execute -echo. -echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% -echo. -echo Please set the JAVA_HOME variable in your environment to match the -echo location of your Java installation. +echo. 1>&2 +echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 goto fail -:init -@rem Get command-line arguments, handling Windows variants - -if not "%OS%" == "Windows_NT" goto win9xME_args - -:win9xME_args -@rem Slurp the command line arguments. -set CMD_LINE_ARGS= -set _SKIP=2 - -:win9xME_args_slurp -if "x%~1" == "x" goto execute - -set CMD_LINE_ARGS=%* - :execute @rem Setup the command line -set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar + @rem Execute Gradle -"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %CMD_LINE_ARGS% +"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %* :end @rem End local scope for the variables with windows NT shell -if "%ERRORLEVEL%"=="0" goto mainEnd +if %ERRORLEVEL% equ 0 goto mainEnd :fail rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of rem the _cmd.exe /c_ return code! -if not "" == "%GRADLE_EXIT_CONSOLE%" exit 1 -exit /b 1 +set EXIT_CODE=%ERRORLEVEL% +if %EXIT_CODE% equ 0 set EXIT_CODE=1 +if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE% +exit /b %EXIT_CODE% :mainEnd if "%OS%"=="Windows_NT" endlocal diff --git a/settings.gradle b/settings.gradle index 231bc04f..afcad4c1 100644 --- a/settings.gradle +++ b/settings.gradle @@ -1,2 +1,24 @@ +pluginManagement { + repositories { + google { + content { + includeGroupByRegex("com\\.android.*") + includeGroupByRegex("com\\.google.*") + includeGroupByRegex("androidx.*") + } + } + mavenCentral() + gradlePluginPortal() + } +} +dependencyResolutionManagement { + repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS) + repositories { + google() + mavenCentral() + maven { url "https://jitpack.io" } // For StfalconImageViewer + } +} + rootProject.name='ntfy' include ':app'