Resolve conflicts

This commit is contained in:
Philipp Heckel 2025-12-28 15:11:37 -05:00
parent 678747e783
commit c6f1b31855
77 changed files with 2933 additions and 580 deletions

View file

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

View file

@ -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">
<!-- Main activity -->
@ -39,6 +40,7 @@
<activity
android:name=".ui.DetailActivity"
android:parentActivityName=".ui.MainActivity"
android:windowSoftInputMode="adjustResize"
android:exported="true">
<meta-data
android:name="android.support.PARENT_ACTIVITY"

View file

@ -6,15 +6,20 @@ import android.media.MediaPlayer
import android.os.Build
import androidx.annotation.WorkerThread
import androidx.appcompat.app.AppCompatDelegate
import androidx.lifecycle.*
import androidx.core.content.edit
import androidx.lifecycle.LiveData
import androidx.lifecycle.MediatorLiveData
import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.asLiveData
import androidx.lifecycle.map
import com.google.gson.Gson
import com.google.gson.reflect.TypeToken
import io.heckel.ntfy.util.Log
import io.heckel.ntfy.util.validUrl
import java.util.concurrent.ConcurrentHashMap
import java.util.concurrent.atomic.AtomicLong
import com.google.gson.Gson
import com.google.gson.reflect.TypeToken
class Repository(private val sharedPrefs: SharedPreferences, private val database: Database) {
class Repository(private val sharedPrefs: SharedPreferences, database: Database) {
private val subscriptionDao = database.subscriptionDao()
private val notificationDao = database.notificationDao()
private val userDao = database.userDao()
@ -192,9 +197,9 @@ class Repository(private val sharedPrefs: SharedPreferences, private val databas
}
fun setPollWorkerVersion(version: Int) {
sharedPrefs.edit()
.putInt(SHARED_PREFS_POLL_WORKER_VERSION, version)
.apply()
sharedPrefs.edit {
putInt(SHARED_PREFS_POLL_WORKER_VERSION, version)
}
}
fun getDeleteWorkerVersion(): Int {
@ -202,9 +207,9 @@ class Repository(private val sharedPrefs: SharedPreferences, private val databas
}
fun setDeleteWorkerVersion(version: Int) {
sharedPrefs.edit()
.putInt(SHARED_PREFS_DELETE_WORKER_VERSION, version)
.apply()
sharedPrefs.edit {
putInt(SHARED_PREFS_DELETE_WORKER_VERSION, version)
}
}
fun getAutoRestartWorkerVersion(): Int {
@ -212,20 +217,20 @@ class Repository(private val sharedPrefs: SharedPreferences, private val databas
}
fun setAutoRestartWorkerVersion(version: Int) {
sharedPrefs.edit()
.putInt(SHARED_PREFS_AUTO_RESTART_WORKER_VERSION, version)
.apply()
sharedPrefs.edit {
putInt(SHARED_PREFS_AUTO_RESTART_WORKER_VERSION, version)
}
}
fun setMinPriority(minPriority: Int) {
if (minPriority <= MIN_PRIORITY_ANY) {
sharedPrefs.edit()
.remove(SHARED_PREFS_MIN_PRIORITY)
.apply()
sharedPrefs.edit {
remove(SHARED_PREFS_MIN_PRIORITY)
}
} else {
sharedPrefs.edit()
.putInt(SHARED_PREFS_MIN_PRIORITY, minPriority)
.apply()
sharedPrefs.edit {
putInt(SHARED_PREFS_MIN_PRIORITY, minPriority)
}
}
}
@ -243,9 +248,9 @@ class Repository(private val sharedPrefs: SharedPreferences, private val databas
}
fun setAutoDownloadMaxSize(maxSize: Long) {
sharedPrefs.edit()
.putLong(SHARED_PREFS_AUTO_DOWNLOAD_MAX_SIZE, maxSize)
.apply()
sharedPrefs.edit {
putLong(SHARED_PREFS_AUTO_DOWNLOAD_MAX_SIZE, maxSize)
}
}
fun getAutoDeleteSeconds(): Long {
@ -253,20 +258,20 @@ class Repository(private val sharedPrefs: SharedPreferences, private val databas
}
fun setAutoDeleteSeconds(seconds: Long) {
sharedPrefs.edit()
.putLong(SHARED_PREFS_AUTO_DELETE_SECONDS, seconds)
.apply()
sharedPrefs.edit {
putLong(SHARED_PREFS_AUTO_DELETE_SECONDS, seconds)
}
}
fun setDarkMode(mode: Int) {
if (mode == AppCompatDelegate.MODE_NIGHT_FOLLOW_SYSTEM) {
sharedPrefs.edit()
.remove(SHARED_PREFS_DARK_MODE)
.apply()
sharedPrefs.edit {
remove(SHARED_PREFS_DARK_MODE)
}
} else {
sharedPrefs.edit()
.putInt(SHARED_PREFS_DARK_MODE, mode)
.apply()
sharedPrefs.edit {
putInt(SHARED_PREFS_DARK_MODE, mode)
}
}
}
@ -275,9 +280,9 @@ class Repository(private val sharedPrefs: SharedPreferences, private val databas
}
fun setDynamicColorsEnabled(enabled: Boolean) {
sharedPrefs.edit()
.putBoolean(SHARED_PREFS_DYNAMIC_COLORS, enabled)
.commit()
sharedPrefs.edit(commit = true) {
putBoolean(SHARED_PREFS_DYNAMIC_COLORS, enabled)
}
}
fun getDynamicColorsEnabled(): Boolean {
@ -285,9 +290,9 @@ class Repository(private val sharedPrefs: SharedPreferences, private val databas
}
fun setConnectionProtocol(connectionProtocol: String) {
sharedPrefs.edit()
.putString(SHARED_PREFS_CONNECTION_PROTOCOL, connectionProtocol)
.apply()
sharedPrefs.edit {
putString(SHARED_PREFS_CONNECTION_PROTOCOL, connectionProtocol)
}
}
fun getConnectionProtocol(): String {
@ -299,9 +304,9 @@ class Repository(private val sharedPrefs: SharedPreferences, private val databas
}
fun setBroadcastEnabled(enabled: Boolean) {
sharedPrefs.edit()
.putBoolean(SHARED_PREFS_BROADCAST_ENABLED, enabled)
.apply()
sharedPrefs.edit {
putBoolean(SHARED_PREFS_BROADCAST_ENABLED, enabled)
}
}
fun getUnifiedPushEnabled(): Boolean {
@ -309,9 +314,9 @@ class Repository(private val sharedPrefs: SharedPreferences, private val databas
}
fun setUnifiedPushEnabled(enabled: Boolean) {
sharedPrefs.edit()
.putBoolean(SHARED_PREFS_UNIFIEDPUSH_ENABLED, enabled)
.apply()
sharedPrefs.edit {
putBoolean(SHARED_PREFS_UNIFIEDPUSH_ENABLED, enabled)
}
}
fun getInsistentMaxPriorityEnabled(): Boolean {
@ -319,9 +324,9 @@ class Repository(private val sharedPrefs: SharedPreferences, private val databas
}
fun setInsistentMaxPriorityEnabled(enabled: Boolean) {
sharedPrefs.edit()
.putBoolean(SHARED_PREFS_INSISTENT_MAX_PRIORITY_ENABLED, enabled)
.apply()
sharedPrefs.edit {
putBoolean(SHARED_PREFS_INSISTENT_MAX_PRIORITY_ENABLED, enabled)
}
}
fun getRecordLogs(): Boolean {
@ -329,9 +334,19 @@ class Repository(private val sharedPrefs: SharedPreferences, private val databas
}
fun setRecordLogsEnabled(enabled: Boolean) {
sharedPrefs.edit()
.putBoolean(SHARED_PREFS_RECORD_LOGS_ENABLED, enabled)
.apply()
sharedPrefs.edit {
putBoolean(SHARED_PREFS_RECORD_LOGS_ENABLED, enabled)
}
}
fun getMessageBarEnabled(): Boolean {
return sharedPrefs.getBoolean(SHARED_PREFS_MESSAGE_BAR_ENABLED, true) // Enabled by default
}
fun setMessageBarEnabled(enabled: Boolean) {
sharedPrefs.edit {
putBoolean(SHARED_PREFS_MESSAGE_BAR_ENABLED, enabled)
}
}
fun getBatteryOptimizationsRemindTime(): Long {
@ -339,9 +354,9 @@ class Repository(private val sharedPrefs: SharedPreferences, private val databas
}
fun setBatteryOptimizationsRemindTime(timeMillis: Long) {
sharedPrefs.edit()
.putLong(SHARED_PREFS_BATTERY_OPTIMIZATIONS_REMIND_TIME, timeMillis)
.apply()
sharedPrefs.edit {
putLong(SHARED_PREFS_BATTERY_OPTIMIZATIONS_REMIND_TIME, timeMillis)
}
}
fun getWebSocketRemindTime(): Long {
@ -349,9 +364,9 @@ class Repository(private val sharedPrefs: SharedPreferences, private val databas
}
fun setWebSocketRemindTime(timeMillis: Long) {
sharedPrefs.edit()
.putLong(SHARED_PREFS_WEBSOCKET_REMIND_TIME, timeMillis)
.apply()
sharedPrefs.edit {
putLong(SHARED_PREFS_WEBSOCKET_REMIND_TIME, timeMillis)
}
}
fun getWebSocketReconnectRemindTime(): Long {
@ -359,9 +374,9 @@ class Repository(private val sharedPrefs: SharedPreferences, private val databas
}
fun setWebSocketReconnectRemindTime(timeMillis: Long) {
sharedPrefs.edit()
.putLong(SHARED_PREFS_WEBSOCKET_RECONNECT_REMIND_TIME, timeMillis)
.apply()
sharedPrefs.edit {
putLong(SHARED_PREFS_WEBSOCKET_RECONNECT_REMIND_TIME, timeMillis)
}
}
fun getDefaultBaseUrl(): String? {
@ -371,16 +386,15 @@ class Repository(private val sharedPrefs: SharedPreferences, private val databas
fun setDefaultBaseUrl(baseUrl: String) {
if (baseUrl == "") {
sharedPrefs
.edit()
.remove(SHARED_PREFS_UNIFIED_PUSH_BASE_URL) // Remove legacy key
.remove(SHARED_PREFS_DEFAULT_BASE_URL)
.apply()
sharedPrefs.edit {
remove(SHARED_PREFS_UNIFIED_PUSH_BASE_URL) // Remove legacy key
.remove(SHARED_PREFS_DEFAULT_BASE_URL)
}
} else {
sharedPrefs.edit()
.remove(SHARED_PREFS_UNIFIED_PUSH_BASE_URL) // Remove legacy key
.putString(SHARED_PREFS_DEFAULT_BASE_URL, baseUrl)
.apply()
sharedPrefs.edit {
remove(SHARED_PREFS_UNIFIED_PUSH_BASE_URL) // Remove legacy key
.putString(SHARED_PREFS_DEFAULT_BASE_URL, baseUrl)
}
}
}
@ -394,18 +408,18 @@ class Repository(private val sharedPrefs: SharedPreferences, private val databas
}
fun setGlobalMutedUntil(mutedUntilTimestamp: Long) {
sharedPrefs.edit()
.putLong(SHARED_PREFS_MUTED_UNTIL_TIMESTAMP, mutedUntilTimestamp)
.apply()
sharedPrefs.edit {
putLong(SHARED_PREFS_MUTED_UNTIL_TIMESTAMP, mutedUntilTimestamp)
}
}
fun checkGlobalMutedUntil(): Boolean {
val mutedUntil = sharedPrefs.getLong(SHARED_PREFS_MUTED_UNTIL_TIMESTAMP, 0L)
val expired = mutedUntil > 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<SubscriptionWithMetadata>): List<Subscription> {
@ -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"

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -103,7 +103,7 @@ class CustomHeaderFragment : DialogFragment() {
if (header != null) {
dialog
.getButton(AlertDialog.BUTTON_NEUTRAL)
.dangerButton(requireContext())
.dangerButton()
}
// Validate input when typing

View file

@ -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<DetailViewModel> {
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<View>(R.id.detail_content_layout)
ViewCompat.setOnApplyWindowInsetsListener(contentLayout) { view, insets ->
val systemBars = insets.getInsets(WindowInsetsCompat.Type.systemBars())
val ime = insets.getInsets(WindowInsetsCompat.Type.ime())
// Use the larger of navigation bar or keyboard height
val bottomPadding = maxOf(systemBars.bottom, ime.bottom)
view.setPadding(view.paddingLeft, view.paddingTop, view.paddingRight, bottomPadding)
insets
}
} else {
// Show FAB, hide message bar
fab.visibility = View.VISIBLE
messageBar.visibility = View.GONE
fab.setOnClickListener {
openPublishDialog("")
}
// Add bottom padding to FAB to account for navigation bar
ViewCompat.setOnApplyWindowInsetsListener(fab) { view, insets ->
val systemBars = insets.getInsets(WindowInsetsCompat.Type.systemBars())
val layoutParams = view.layoutParams as androidx.coordinatorlayout.widget.CoordinatorLayout.LayoutParams
layoutParams.bottomMargin = systemBars.bottom + resources.getDimensionPixelSize(R.dimen.fab_margin)
view.layoutParams = layoutParams
insets
}
}
}
private fun openPublishDialog(initialMessage: String) {
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()
}

View file

@ -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<Notification, DetailAdapter.DetailViewHolder>(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<Any?>(context, listOf(bitmap)) { imageView, image ->
StfalconImageViewer.Builder<Any?>(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)

View file

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

View file

@ -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<SubscriptionsViewModel> {
@ -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<View>(R.id.main_banner_battery)
batteryBanner.visibility = View.GONE
}
// Hide, at least for now
val batteryBanner = findViewById<View>(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()
}

View file

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

View file

@ -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<List<Subscription>> {
@ -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
}

View file

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

View file

@ -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<PriorityItem>
) : ArrayAdapter<PriorityItem>(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<ImageView>(R.id.priority_icon)
val textView = view.findViewById<TextView>(R.id.priority_text)
iconView.setImageResource(item.iconResId)
textView.text = item.label
return view
}
companion object {
fun createPriorityItems(context: Context): List<PriorityItem> {
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)
)
}
}
}

View file

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

View file

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

View file

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

View file

@ -102,7 +102,7 @@ class UserFragment : DialogFragment() {
if (user != null) {
dialog
.getButton(AlertDialog.BUTTON_NEUTRAL)
.dangerButton(requireContext())
.dangerButton()
}
// Validate input when typing

View file

@ -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<String> 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<String> getAliases() {

View file

@ -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<String> aliases = jsonArrayToStringList(json.getJSONArray("aliases"));
return new Emoji(aliases, bytes);
}

View file

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

View file

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

View file

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

View file

@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<selector xmlns:android="http://schemas.android.com/apk/res/android">
<item android:color="@color/chip_background_checked" android:state_checked="true"/>
<item android:color="@color/chip_background_color"/>
</selector>

View file

@ -0,0 +1,9 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:pathData="M3,17.25V21h3.75L17.81,9.94l-3.75,-3.75L3,17.25zM20.71,7.04c0.39,-0.39 0.39,-1.02 0,-1.41l-2.34,-2.34c-0.39,-0.39 -1.02,-0.39 -1.41,0l-1.83,1.83 3.75,3.75 1.83,-1.83z"
android:fillColor="#FFFFFF"/>
</vector>

View file

@ -0,0 +1,10 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:pathData="M12,8l-6,6 1.41,1.41L12,10.83l4.59,4.58L18,14z"
android:fillColor="#808080"/>
</vector>

View file

@ -0,0 +1,13 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:pathData="M4,10h16v1.5H4z"
android:fillColor="#2196F3"/>
<path
android:pathData="M4,13h16v1.5H4z"
android:fillColor="#2196F3"/>
</vector>

View file

@ -0,0 +1,9 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:pathData="M4.01,6.03l7.51,3.22 -7.52,-1 0.01,-2.22m7.5,8.72L4,17.97v-2.22l7.51,-1M2.01,3L2,10l15,2 -15,2 0.01,7L23,12 2.01,3z"
android:fillColor="#808080"/>
</vector>

View file

@ -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">
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/detail_notification_list"
android:layout_width="match_parent"
@ -45,8 +48,10 @@
android:orientation="vertical"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:id="@+id/detail_no_notifications" app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintTop_toTopOf="parent" app:layout_constraintStart_toStartOf="parent"
android:id="@+id/detail_no_notifications"
app:layout_constraintBottom_toTopOf="@id/detail_message_bar"
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintEnd_toEndOf="parent">
<ImageView
android:layout_width="match_parent"
@ -88,6 +93,28 @@
android:autoLink="web"/>
</LinearLayout>
<include
android:id="@+id/detail_message_bar"
layout="@layout/view_message_bar"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:visibility="gone"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintEnd_toEndOf="parent"/>
</androidx.constraintlayout.widget.ConstraintLayout>
<com.google.android.material.floatingactionbutton.FloatingActionButton
android:id="@+id/detail_fab"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_margin="24dp"
android:contentDescription="@string/detail_fab_publish_description"
android:src="@drawable/ic_create_white_24dp"
android:visibility="gone"
app:layout_anchor="@id/detail_content_layout"
app:layout_anchorGravity="bottom|end"
style="@style/FloatingActionButton"/>
</androidx.coordinatorlayout.widget.CoordinatorLayout>

View file

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

View file

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

View file

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

View file

@ -0,0 +1,430 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.coordinatorlayout.widget.CoordinatorLayout
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:background="?attr/colorSurface"
android:fitsSystemWindows="true">
<com.google.android.material.appbar.AppBarLayout
android:id="@+id/publish_dialog_app_bar"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:background="?attr/colorSurface"
app:elevation="0dp"
app:liftOnScroll="false">
<com.google.android.material.appbar.MaterialToolbar
android:id="@+id/publish_dialog_toolbar"
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:titleTextColor="?attr/colorOnSurface"
app:menu="@menu/menu_publish_dialog" />
</com.google.android.material.appbar.AppBarLayout>
<!-- Upload progress indicator (below toolbar, doesn't shift content) -->
<com.google.android.material.progressindicator.LinearProgressIndicator
android:id="@+id/publish_dialog_upload_progress"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:visibility="gone"
android:indeterminate="false"
android:max="100"
app:trackThickness="4dp"
app:layout_behavior="@string/appbar_scrolling_view_behavior"/>
<LinearLayout
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="vertical"
android:paddingHorizontal="?dialogPreferredPadding"
app:layout_behavior="@string/appbar_scrolling_view_behavior">
<ScrollView
android:id="@+id/publish_dialog_scroll_view"
android:layout_width="match_parent"
android:layout_height="match_parent">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical">
<!-- Upload progress text -->
<TextView
android:id="@+id/publish_dialog_upload_progress_text"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:textAlignment="center"
android:textAppearance="@style/TextAppearance.Material3.BodyMedium"
android:textColor="?attr/colorPrimary"
android:visibility="gone"/>
<!-- Title (toggleable) -->
<com.google.android.material.textfield.TextInputLayout
android:id="@+id/publish_dialog_title_layout"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:visibility="gone"
android:layout_marginTop="8dp"
app:placeholderText="@string/publish_dialog_title_placeholder">
<com.google.android.material.textfield.TextInputEditText
android:id="@+id/publish_dialog_title_text"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:hint="@string/publish_dialog_title_hint"
android:importantForAutofill="no"
android:maxLines="1"
android:inputType="text"/>
</com.google.android.material.textfield.TextInputLayout>
<!-- Message -->
<com.google.android.material.textfield.TextInputLayout
android:id="@+id/publish_dialog_message_layout"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="10dp"
app:placeholderText="@string/message_bar_hint"> <!-- Re-use message bar hint -->
<com.google.android.material.textfield.TextInputEditText
android:id="@+id/publish_dialog_message_text"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:hint="@string/publish_dialog_message_hint"
android:importantForAutofill="no"
android:minLines="3"
android:gravity="start|top"
android:inputType="textMultiLine|textCapSentences"/>
</com.google.android.material.textfield.TextInputLayout>
<!-- Tags (toggleable) -->
<com.google.android.material.textfield.TextInputLayout
android:id="@+id/publish_dialog_tags_layout"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:visibility="gone"
android:layout_marginTop="10dp"
app:placeholderText="@string/publish_dialog_tags_placeholder">
<com.google.android.material.textfield.TextInputEditText
android:id="@+id/publish_dialog_tags_text"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:hint="@string/publish_dialog_tags_hint"
android:importantForAutofill="no"
android:maxLines="1"
android:inputType="text"/>
</com.google.android.material.textfield.TextInputLayout>
<!-- Priority (toggleable) -->
<com.google.android.material.textfield.TextInputLayout
android:id="@+id/publish_dialog_priority_layout"
style="@style/Widget.MaterialComponents.TextInputLayout.OutlinedBox.ExposedDropdownMenu"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:visibility="gone"
android:layout_marginTop="10dp"
android:hint="@string/publish_dialog_priority_hint">
<AutoCompleteTextView
android:id="@+id/publish_dialog_priority_dropdown"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:inputType="none"/>
</com.google.android.material.textfield.TextInputLayout>
<!-- Click URL field -->
<com.google.android.material.textfield.TextInputLayout
android:id="@+id/publish_dialog_click_url_layout"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:visibility="gone"
android:layout_marginTop="10dp"
app:placeholderText="@string/publish_dialog_click_url_placeholder">
<com.google.android.material.textfield.TextInputEditText
android:id="@+id/publish_dialog_click_url_text"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:hint="@string/publish_dialog_click_url_hint"
android:importantForAutofill="no"
android:maxLines="1"
android:inputType="textUri"/>
</com.google.android.material.textfield.TextInputLayout>
<!-- Email field -->
<com.google.android.material.textfield.TextInputLayout
android:id="@+id/publish_dialog_email_layout"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:visibility="gone"
android:layout_marginTop="10dp"
app:placeholderText="@string/publish_dialog_email_placeholder">
<com.google.android.material.textfield.TextInputEditText
android:id="@+id/publish_dialog_email_text"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:hint="@string/publish_dialog_email_hint"
android:importantForAutofill="no"
android:maxLines="1"
android:inputType="textEmailAddress"/>
</com.google.android.material.textfield.TextInputLayout>
<!-- Delay field -->
<com.google.android.material.textfield.TextInputLayout
android:id="@+id/publish_dialog_delay_layout"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:visibility="gone"
android:layout_marginTop="10dp"
app:placeholderText="@string/publish_dialog_delay_placeholder">
<com.google.android.material.textfield.TextInputEditText
android:id="@+id/publish_dialog_delay_text"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:hint="@string/publish_dialog_delay_hint"
android:importantForAutofill="no"
android:maxLines="1"
android:inputType="text"/>
</com.google.android.material.textfield.TextInputLayout>
<!-- Attach URL field -->
<com.google.android.material.textfield.TextInputLayout
android:id="@+id/publish_dialog_attach_url_layout"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:visibility="gone"
android:layout_marginTop="10dp"
app:placeholderText="@string/publish_dialog_attach_url_placeholder">
<com.google.android.material.textfield.TextInputEditText
android:id="@+id/publish_dialog_attach_url_text"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:hint="@string/publish_dialog_attach_url_hint"
android:importantForAutofill="no"
android:maxLines="1"
android:inputType="textUri"/>
</com.google.android.material.textfield.TextInputLayout>
<!-- Attachment box (shown after local file is selected) -->
<include
android:id="@+id/publish_dialog_attachment_box"
layout="@layout/view_attachment_box"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="10dp"
android:visibility="gone"/>
<!-- Attachment filename field (shared between attach URL and attach file) -->
<com.google.android.material.textfield.TextInputLayout
android:id="@+id/publish_dialog_attach_filename_layout"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:visibility="gone"
android:layout_marginTop="8dp"
app:placeholderText="@string/publish_dialog_attach_filename_placeholder">
<com.google.android.material.textfield.TextInputEditText
android:id="@+id/publish_dialog_attach_filename_text"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:hint="@string/publish_dialog_attach_filename_hint"
android:importantForAutofill="no"
android:maxLines="1"
android:inputType="text"/>
</com.google.android.material.textfield.TextInputLayout>
<!-- Phone Call field -->
<com.google.android.material.textfield.TextInputLayout
android:id="@+id/publish_dialog_phone_call_layout"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:visibility="gone"
android:layout_marginTop="10dp"
app:placeholderText="@string/publish_dialog_phone_call_placeholder">
<com.google.android.material.textfield.TextInputEditText
android:id="@+id/publish_dialog_phone_call_text"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:hint="@string/publish_dialog_phone_call_hint"
android:importantForAutofill="no"
android:maxLines="1"
android:inputType="phone"/>
</com.google.android.material.textfield.TextInputLayout>
<!-- Feature chips -->
<com.google.android.material.chip.ChipGroup
android:id="@+id/publish_dialog_chip_group"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
app:chipSpacingVertical="-4dp"
app:chipSpacingHorizontal="8dp"
app:singleLine="false">
<com.google.android.material.chip.Chip
android:id="@+id/publish_dialog_chip_markdown"
style="@style/Widget.Material3.Chip.Filter"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/publish_dialog_chip_markdown"
app:chipBackgroundColor="@color/chip_background_state"
app:chipStrokeWidth="0dp"/>
<com.google.android.material.chip.Chip
android:id="@+id/publish_dialog_chip_title"
style="@style/Widget.Material3.Chip.Filter"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/publish_dialog_chip_title"
app:chipBackgroundColor="@color/chip_background_state"
app:chipStrokeWidth="0dp"/>
<com.google.android.material.chip.Chip
android:id="@+id/publish_dialog_chip_tags"
style="@style/Widget.Material3.Chip.Filter"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/publish_dialog_chip_tags"
app:chipBackgroundColor="@color/chip_background_state"
app:chipStrokeWidth="0dp"/>
<com.google.android.material.chip.Chip
android:id="@+id/publish_dialog_chip_priority"
style="@style/Widget.Material3.Chip.Filter"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/publish_dialog_chip_priority"
app:chipBackgroundColor="@color/chip_background_state"
app:chipStrokeWidth="0dp"/>
<com.google.android.material.chip.Chip
android:id="@+id/publish_dialog_chip_click_url"
style="@style/Widget.Material3.Chip.Filter"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/publish_dialog_chip_click_url"
app:chipBackgroundColor="@color/chip_background_state"
app:chipStrokeWidth="0dp"/>
<com.google.android.material.chip.Chip
android:id="@+id/publish_dialog_chip_email"
style="@style/Widget.Material3.Chip.Filter"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/publish_dialog_chip_email"
app:chipBackgroundColor="@color/chip_background_state"
app:chipStrokeWidth="0dp"/>
<com.google.android.material.chip.Chip
android:id="@+id/publish_dialog_chip_delay"
style="@style/Widget.Material3.Chip.Filter"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/publish_dialog_chip_delay"
app:chipBackgroundColor="@color/chip_background_state"
app:chipStrokeWidth="0dp"/>
<com.google.android.material.chip.Chip
android:id="@+id/publish_dialog_chip_attach_url"
style="@style/Widget.Material3.Chip.Filter"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/publish_dialog_chip_attach_url"
app:chipBackgroundColor="@color/chip_background_state"
app:chipStrokeWidth="0dp"/>
<com.google.android.material.chip.Chip
android:id="@+id/publish_dialog_chip_attach_file"
style="@style/Widget.Material3.Chip.Filter"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/publish_dialog_chip_attach_file"
app:chipBackgroundColor="@color/chip_background_state"
app:chipStrokeWidth="0dp"/>
<com.google.android.material.chip.Chip
android:id="@+id/publish_dialog_chip_phone_call"
style="@style/Widget.Material3.Chip.Filter"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/publish_dialog_chip_phone_call"
app:chipBackgroundColor="@color/chip_background_state"
app:chipStrokeWidth="0dp"/>
</com.google.android.material.chip.ChipGroup>
<!-- Documentation link -->
<TextView
android:id="@+id/publish_dialog_docs_text"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="16dp"
android:layout_marginBottom="16dp"
android:text="@string/publish_dialog_docs_text"
android:textColor="?android:attr/textColorSecondary"
android:textAppearance="@style/TextAppearance.Material3.BodySmall"/>
<!-- Error section -->
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal"
android:layout_marginTop="10dp"
android:gravity="center_vertical">
<ImageView
android:id="@+id/publish_dialog_error_image"
android:layout_width="20dp"
android:layout_height="20dp"
android:visibility="gone"
app:srcCompat="@drawable/ic_error_red_24dp"/>
<TextView
android:id="@+id/publish_dialog_error_text"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:paddingStart="4dp"
android:paddingEnd="4dp"
android:textAppearance="@style/DangerText"
android:visibility="gone"/>
</LinearLayout>
<!-- Bottom padding for keyboard -->
<View
android:layout_width="match_parent"
android:layout_height="100dp"/>
</LinearLayout>
</ScrollView>
</LinearLayout>
</androidx.coordinatorlayout.widget.CoordinatorLayout>

View file

@ -0,0 +1,28 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal"
android:gravity="center_vertical"
android:paddingHorizontal="16dp"
android:paddingVertical="12dp"
android:minHeight="48dp">
<ImageView
android:id="@+id/priority_icon"
android:layout_width="20dp"
android:layout_height="20dp"
android:layout_marginEnd="12dp"
android:contentDescription="@null"
app:srcCompat="@drawable/ic_priority_3_24dp"/>
<TextView
android:id="@+id/priority_text"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:textSize="16sp"
android:textColor="?android:attr/textColorPrimary"/>
</LinearLayout>

View file

@ -0,0 +1,53 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal"
android:gravity="center_vertical">
<!-- Icon and size stacked vertically on the left -->
<LinearLayout
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:orientation="vertical"
android:gravity="center_horizontal"
android:layout_marginEnd="12dp">
<ImageView
android:id="@+id/attachment_box_icon"
android:layout_width="32dp"
android:layout_height="32dp"
android:contentDescription="@null"
app:srcCompat="@drawable/ic_file_document_blue_24dp"/>
<TextView
android:id="@+id/attachment_box_size"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="2dp"
android:textColor="?android:attr/textColorSecondary"
android:textAppearance="@style/TextAppearance.Material3.LabelSmall"/>
</LinearLayout>
<!-- Filename text field on the right -->
<com.google.android.material.textfield.TextInputLayout
android:id="@+id/attachment_box_filename_layout"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:hint="@string/publish_dialog_attach_filename_hint"
app:placeholderText="@string/publish_dialog_attach_filename_placeholder">
<com.google.android.material.textfield.TextInputEditText
android:id="@+id/attachment_box_filename"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:importantForAutofill="no"
android:maxLines="1"
android:inputType="text"/>
</com.google.android.material.textfield.TextInputLayout>
</LinearLayout>

View file

@ -0,0 +1,83 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:background="@color/detail_activity_background"
android:paddingStart="8dp"
android:paddingEnd="8dp"
android:paddingTop="6dp"
android:paddingBottom="6dp">
<com.google.android.material.card.MaterialCardView
android:id="@+id/message_bar_card"
android:layout_width="0dp"
android:layout_height="wrap_content"
app:cardCornerRadius="16dp"
app:cardElevation="0dp"
app:strokeWidth="0dp"
app:cardBackgroundColor="?android:attr/colorBackground"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintEnd_toStartOf="@id/message_bar_publish_button"
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintBottom_toBottomOf="parent"
android:layout_marginEnd="6dp">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal"
android:gravity="center_vertical">
<ImageButton
android:id="@+id/message_bar_expand_button"
android:layout_width="40dp"
android:layout_height="40dp"
android:src="@drawable/ic_expand_less_gray_24dp"
android:background="?attr/selectableItemBackgroundBorderless"
android:contentDescription="@string/message_bar_expand_button_description"
app:tint="?attr/colorOutline"
android:layout_marginStart="4dp"/>
<com.google.android.material.textfield.TextInputEditText
android:id="@+id/message_bar_text"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:hint="@string/message_bar_hint"
android:textColorHint="?attr/colorOutline"
android:inputType="textMultiLine|textCapSentences"
android:minHeight="48dp"
android:maxLines="4"
android:background="@null"
android:paddingStart="4dp"
android:paddingEnd="16dp"
android:paddingTop="12dp"
android:paddingBottom="12dp"
android:importantForAutofill="no"/>
</LinearLayout>
</com.google.android.material.card.MaterialCardView>
<com.google.android.material.floatingactionbutton.FloatingActionButton
android:id="@+id/message_bar_publish_button"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:src="@drawable/ic_send_white_24dp"
android:contentDescription="@string/message_bar_publish_button_description"
android:clickable="true"
android:focusable="true"
app:fabSize="mini"
app:fabCustomSize="48dp"
app:maxImageSize="24dp"
app:tint="?attr/colorOnPrimary"
app:backgroundTint="?attr/colorPrimary"
app:rippleColor="#40FFFFFF"
app:elevation="2dp"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintBottom_toBottomOf="parent"/>
</androidx.constraintlayout.widget.ConstraintLayout>

View file

@ -0,0 +1,9 @@
<?xml version="1.0" encoding="utf-8"?>
<menu xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto">
<item
android:id="@+id/publish_dialog_publish_button"
android:title="@string/publish_dialog_button_publish"
android:enabled="false"
app:showAsAction="always" />
</menu>

View file

@ -349,4 +349,7 @@
<string name="settings_advanced_exact_alarms_title">Будилници в точно време</string>
<string name="settings_advanced_exact_alarms_true">ntfy може да насрочва будилници в точно определено време. Те са задължителни за повторно свързване на WebSockets във фонов режим. За да оттеглите разрешението докоснете.</string>
<string name="settings_advanced_exact_alarms_false">ntfy не може да насрочва будилници в точно определено време. Те са задължителни за повторно свързване на WebSockets във фонов режим. За да разрешите докоснете.</string>
<string name="settings_general_dynamic_colors_title">Динамични цветове</string>
<string name="settings_general_dynamic_colors_summary_enabled">Използване на динамичните системни цветове</string>
<string name="settings_general_dynamic_colors_summary_disabled">Използване на цветовете от темата на ntfy</string>
</resources>

View file

@ -349,17 +349,12 @@
<string name="settings_advanced_exact_alarms_title">Genaue Alarme</string>
<string name="settings_advanced_exact_alarms_true">ntfy kann genaue Alarme planen. Um WebSockets im Hintergrund wieder zu verbinden, sind genaue Alarme erforderlich. Hier tippen, um die Berechtigung zu widerrufen.</string>
<string name="settings_advanced_exact_alarms_false">ntfy kann keine genauen Alarme planen. Um WebSockets im Hintergrund wieder zu verbinden, sind genaue Alarme erforderlich. Tippe hier, um die Berechtigung zu erteilen.</string>
<!-- Custom Headers settings -->
<string name="settings_general_custom_headers_key">BenutzerdefinierteHeader</string>
<string name="settings_general_custom_headers_title">Benutzerdefinierte Header</string>
<string name="settings_general_custom_headers_summary">Benutzerdefinierte HTTP-Header pro Server hinzufügen</string>
<string name="settings_general_custom_headers_prefs_title">Benutzerdefinierte Header</string>
<string name="settings_general_custom_headers_prefs_header_add">Header hinzufügen</string>
<string name="settings_general_custom_headers_prefs_header_add_title">Header für einen Server hinzufügen</string>
<string name="settings_general_custom_headers_prefs_header_add_summary">Header werden mit jeder HTTP-Anfrage an diesen Server gesendet</string>
<!-- Custom Headers dialog -->
<string name="custom_headers_add_header">Header hinzufügen</string>
<string name="custom_headers_add_summary">Neuen benutzerdefinierten HTTP-Header hinzufügen</string>
<string name="custom_headers_add_title">Neuen Header hinzufügen</string>
@ -377,4 +372,7 @@
<string name="custom_header_dialog_description_add">Einen benutzerdefinierten HTTP-Header hinzufügen, der mit jeder Anfrage an den angegebenen Server gesendet wird.</string>
<string name="custom_header_dialog_description_edit">Den Header-Namen/Wert bearbeiten oder löschen.</string>
<string name="custom_header_dialog_base_url_hint">Service-URL</string>
<string name="settings_general_dynamic_colors_title">Dynamische Farben</string>
<string name="settings_general_dynamic_colors_summary_enabled">Verwendung der dynamischen Systemfarben</string>
<string name="settings_general_dynamic_colors_summary_disabled">Verwendung der ntfy-Themenfarben</string>
</resources>

View file

@ -339,4 +339,7 @@
<string name="detail_settings_notifications_open_channels_summary">Helimärguanded, „Ära sega“ olekuga mittearvestamine, jne.</string>
<string name="detail_settings_notifications_insistent_max_priority_list_item_enabled">Jätka pidevate märguannetega</string>
<string name="detail_settings_notifications_insistent_max_priority_list_item_disabled">Anna märku vaid üks kord</string>
<string name="settings_general_dynamic_colors_title">Kasuta dünaamilist värvivalikut</string>
<string name="settings_general_dynamic_colors_summary_enabled">Kasutan dünaamilist süsteemi värvide valikut</string>
<string name="settings_general_dynamic_colors_summary_disabled">Kasutan ntfy kujunduste valikut</string>
</resources>

View file

@ -51,4 +51,50 @@
<string name="add_dialog_use_another_server">از سرور دیگری استفاده کن</string>
<string name="add_dialog_use_another_server_description">برای اشتراک در موضوعات از سرورهای دیگر، نشانی‌های سرویس را در زیر وارد کنید.</string>
<string name="main_how_to_intro">با فشردن + یک موضوع ایجاد کرده یا به آن مشترک شوید. از این پس شما هنگام ارسال توسط PUT یا POST بر روی دستگاه خود اطلاعیه دریافت می کنید.</string>
<string name="main_banner_battery_button_dismiss">رد کردن</string>
<string name="main_banner_websocket_button_dismiss">رد کردن</string>
<string name="add_dialog_button_cancel">لغو</string>
<string name="add_dialog_button_subscribe">اشتراک</string>
<string name="add_dialog_button_back">بازگشت</string>
<string name="add_dialog_login_username_hint">نام کاربری</string>
<string name="add_dialog_login_password_hint">گذرواژه</string>
<string name="detail_clear_dialog_cancel">لغو</string>
<string name="detail_delete_dialog_cancel">لغو</string>
<string name="detail_item_snack_undo">برگردان</string>
<string name="detail_item_download_info_deleted">حذف شده</string>
<string name="detail_menu_unsubscribe">لغو اشتراک</string>
<string name="detail_action_mode_menu_copy">رونوشت</string>
<string name="detail_action_mode_menu_delete">حذف</string>
<string name="detail_action_mode_delete_dialog_cancel">لغو</string>
<string name="share_title">هم‌رسانی</string>
<string name="share_menu_send">هم‌رسانی</string>
<string name="notification_dialog_cancel">لغو</string>
<string name="notification_dialog_save">ذخیره</string>
<string name="notification_popup_action_open">گشودن</string>
<string name="notification_popup_action_browse">مرور</string>
<string name="notification_popup_action_download">بارگیری</string>
<string name="notification_popup_action_cancel">لغو</string>
<string name="settings_title">تنظیمات</string>
<string name="settings_notifications_header">آگاهی‌ها</string>
<string name="settings_notifications_priority_low">کم</string>
<string name="settings_notifications_priority_default">پیش‌گزیده</string>
<string name="settings_notifications_priority_high">زیاد</string>
<string name="settings_notifications_priority_max">بیشینه</string>
<string name="settings_notifications_priority_min">کمینه</string>
<string name="settings_notifications_auto_delete_never">هرگز</string>
<string name="settings_general_header">عمومی</string>
<string name="settings_general_users_prefs_title">کاربران</string>
<string name="settings_backup_restore_backup_entry_everything">همه‌چیز</string>
<string name="settings_advanced_header">پیشرفته</string>
<string name="settings_advanced_export_logs_scrub_dialog_button_ok">قبول</string>
<string name="settings_advanced_connection_protocol_entry_ws">سوکت‌های وب</string>
<string name="settings_about_header">درباره</string>
<string name="settings_about_version_title">نگارش</string>
<string name="detail_settings_appearance_header">ظاهر</string>
<string name="detail_settings_about_header">درباره</string>
<string name="user_dialog_username_hint">نام کاربری</string>
<string name="user_dialog_password_hint_add">گذرواژه</string>
<string name="user_dialog_button_cancel">لغو</string>
<string name="user_dialog_button_save">ذخیره</string>
<string name="main_banner_websocket_reconnect_button_dismiss">رد کردن</string>
</resources>

View file

@ -31,7 +31,7 @@
<string name="add_dialog_login_username_hint">Nom d\'utilisateur</string>
<string name="add_dialog_login_error_not_authorized">Échec de la connexion. L\'utilisateur %1$s n\'est pas autorisé.</string>
<string name="add_dialog_login_new_user">Nouvel utilisateur</string>
<string name="detail_how_to_intro">Pour envoyer des notifications à ce sujet, veuillez simplement PUT ou POST à l\'URL du sujet.</string>
<string name="detail_how_to_intro">Pour envoyer des notifications à ce sujet, faites simplement une requête PUT ou POST à l\'URL du sujet.</string>
<string name="detail_test_title">Test : Vous pouvez mettre un titre si vous le voulez.</string>
<string name="detail_test_message_error">Incapable d\'envoyer le message : %1$s</string>
<string name="detail_copied_to_clipboard_message">Copié dans le presse-papier</string>
@ -94,7 +94,7 @@
<string name="add_dialog_button_login">Se connecter</string>
<string name="add_dialog_login_description">Ce sujet requiert que vous soyez connecté. Veuillez entrer un nom d\'utilisateur et un mot de passe.</string>
<string name="add_dialog_login_password_hint">Mot de passe</string>
<string name="detail_how_to_example">Example (utilisant curl):<br/><tt>$ curl -d \"Bonjour\" %1$s</tt></string>
<string name="detail_how_to_example"><![CDATA[ Exemple (avec curl):<br/><tt>$ curl -d \"Hi\" %1$s</tt> ]]></string>
<string name="detail_clear_dialog_cancel">Annuler</string>
<string name="detail_how_to_link">Des instructions détaillées sont disponible sur ntfy.sh et dans la documentation.</string>
<string name="detail_clear_dialog_message">Supprimer toutes les notifications de ce sujet \?</string>
@ -149,7 +149,7 @@
<string name="settings_backup_restore_restore_failed">Échec de la restauration : %1$s</string>
<string name="user_dialog_password_hint_add">Mot de passe</string>
<string name="user_dialog_password_hint_edit">Mot de passe (pas de changement si laissé vide)</string>
<string name="share_topic_title">Partager</string>
<string name="share_topic_title">Partager à</string>
<string name="share_suggested_topics">Sujets suggérés</string>
<string name="share_successful">Message publié</string>
<string name="notification_dialog_title">Mettre en sourdine les notifications</string>
@ -346,4 +346,10 @@
<string name="main_banner_websocket_reconnect_button_remind_later">Demander plus tard</string>
<string name="main_banner_websocket_reconnect_button_dismiss">Ignorer</string>
<string name="main_banner_websocket_reconnect_button_enable_now">Autoriser</string>
<string name="settings_advanced_exact_alarms_title">Alarmes exactes</string>
<string name="settings_advanced_exact_alarms_true">ntfy peut programmer des alarmes exactes. Les alarmes exactes sont nécessaires pour reconnecter les WebSockets en arrière-plan. Cliquez pour révoquer lautorisation.</string>
<string name="settings_advanced_exact_alarms_false">ntfy ne peut pas programmer dalarmes exactes. Les alarmes exactes sont nécessaires pour reconnecter les WebSockets en arrière-plan. Cliquez pour accorder lautorisation.</string>
<string name="settings_general_dynamic_colors_title">Couleurs dynamiques</string>
<string name="settings_general_dynamic_colors_summary_disabled">Utiliser les couleurs de thème ntfy</string>
<string name="settings_general_dynamic_colors_summary_enabled">Utiliser les couleurs dynamiques du système</string>
</resources>

View file

@ -349,4 +349,7 @@
<string name="settings_advanced_exact_alarms_title">Alarmas exactas</string>
<string name="settings_advanced_exact_alarms_true">ntfy pode pogramar alarmas exactas. Estas alarmas requírense para reconectar en segundo plano con WebSockets. Preme para revogar o permiso.</string>
<string name="settings_advanced_exact_alarms_false">ntfy non pode programar alarmas exactas. Estas alarmas requírense para reconectar en segundo plano con WebSockets. Preme para conceder o permiso.</string>
<string name="settings_general_dynamic_colors_title">Cores dinámicas</string>
<string name="settings_general_dynamic_colors_summary_enabled">Usando as cores dinámicas do sistema</string>
<string name="settings_general_dynamic_colors_summary_disabled">Usando as cores do decorado ntfy</string>
</resources>

View file

@ -257,7 +257,7 @@
<string name="settings_general_dark_mode_entry_dark">Modalità scura</string>
<string name="settings_backup_restore_backup_title">Backup su file</string>
<string name="settings_general_dark_mode_summary_dark">Modalità scura attiva. Sei un vampiro?</string>
<string name="settings_backup_restore_header">Backup &amp; Ripristino</string>
<string name="settings_backup_restore_header">Backup e Ripristino</string>
<string name="settings_backup_restore_restore_failed">Ripristino fallito: %1$s</string>
<string name="settings_advanced_export_logs_copied_logs">Log copiati negli appunti</string>
<string name="settings_about_header">Informazioni</string>
@ -345,4 +345,7 @@
<string name="settings_advanced_exact_alarms_title">Allarmi esatti</string>
<string name="settings_advanced_exact_alarms_true">ntfy può programmare allarmi esatti. Gli allarmi esatti sono necessari per riconnettere i WebSocket in sottofondo. Clicca per revocare l\'autorizzazione.</string>
<string name="settings_advanced_exact_alarms_false">ntfy non può pianificare allarmi esatti. Gli allarmi esatti sono necessari per riconnettere i WebSocket in sottofondo. Clicca per concedere l\'autorizzazione.</string>
<string name="settings_general_dynamic_colors_title">Colori dinamici</string>
<string name="settings_general_dynamic_colors_summary_enabled">Utilizzo dei colori del sistema dinamico</string>
<string name="settings_general_dynamic_colors_summary_disabled">Utilizzo dei colori del tema di ntfy</string>
</resources>

View file

@ -349,4 +349,7 @@
<string name="settings_advanced_exact_alarms_title">正確なアラーム</string>
<string name="settings_advanced_exact_alarms_true">ntfyは正確なアラームをスケジュールできます。正確なアラームはバックグラウンドでWebSocketを再接続するのに使用されます。クリックして権限を取り消す。</string>
<string name="settings_advanced_exact_alarms_false">ntfyは正確なアラームをスケジュールできません。WebSocketsをバックグラウンドで再接続するには正確なアラームが必要です。クリックして権限を許可してください。</string>
<string name="settings_general_dynamic_colors_title">ダイナミックカラー</string>
<string name="settings_general_dynamic_colors_summary_enabled">システムのダイナミックカラーを使用する</string>
<string name="settings_general_dynamic_colors_summary_disabled">ntfyのテーマカラーを使用する</string>
</resources>

View file

@ -144,4 +144,8 @@
<color name="action_bar">#1B2023</color> <!-- md_theme_surfaceContainer (dark) - matches card color -->
<color name="detail_activity_background">#121212</color> <!-- Black for detail activity in dark mode -->
<!-- Chip colors -->
<color name="chip_background_color">#2C2C2C</color> <!-- Dark gray background -->
<color name="chip_background_checked">#4A4A4A</color> <!-- Lighter when selected in dark mode -->
</resources>

View file

@ -8,5 +8,14 @@
<style name="ActionModeTitle" parent="@style/TextAppearance.AppCompat.Widget.ActionMode.Title">
<item name="android:textColor">?attr/colorOnSurface</item>
</style>
</resources>
<!-- Full-screen dialog style for dark mode -->
<style name="Theme.App.FullScreenDialog" parent="AppTheme">
<item name="android:windowNoTitle">true</item>
<item name="android:windowIsFloating">false</item>
<item name="android:statusBarColor">?attr/colorSurface</item>
<item name="android:windowBackground">?attr/colorSurface</item>
<item name="android:windowLightStatusBar">false</item>
<item name="android:windowAnimationStyle">@style/Animation.App.FullScreenDialog</item>
</style>
</resources>

View file

@ -347,4 +347,7 @@
<string name="settings_advanced_exact_alarms_title">Kesin alarmlar</string>
<string name="settings_advanced_exact_alarms_true">ntfy, kesin alarmlar planlayabilir. Kesin alarmlar, WebSocketlerin arka planda yeniden bağlanması için gereklidir. İzni geri almak için tıklayın.</string>
<string name="settings_advanced_exact_alarms_false">ntfy, kesin alarmlar planlayamaz. Kesin alarmlar, WebSocketlerin arka planda yeniden bağlanması için gereklidir. İzni vermek için tıklayın.</string>
<string name="settings_general_dynamic_colors_title">Değişken renkler</string>
<string name="settings_general_dynamic_colors_summary_enabled">Değişken sistem renkleri kullanılıyor</string>
<string name="settings_general_dynamic_colors_summary_disabled">ntfy tema renkleri kullanılıyor</string>
</resources>

View file

@ -1,27 +1,27 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="channel_notifications_low_name">Ưu tiên thấp</string>
<string name="channel_notifications_low_name">Mức ưu tiên thấp</string>
<string name="add_dialog_login_password_hint">Mật khẩu</string>
<string name="add_dialog_login_username_hint">Tên tài khoản</string>
<string name="add_dialog_login_description">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.</string>
<string name="user_dialog_button_add">Thêm người dùng</string>
<string name="add_dialog_login_description">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.</string>
<string name="user_dialog_button_add">Thêm tài khoản</string>
<string name="add_dialog_login_error_not_authorized">Đăng nhập thất bại. Tài khoản %1$s không được cấp quyền.</string>
<string name="user_dialog_description_edit">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.</string>
<string name="user_dialog_button_save">Lưu</string>
<string name="user_dialog_password_hint_add">Mật khẩu</string>
<string name="add_dialog_topic_name_hint">Tên chủ đề, ví dụ: phils_alerts</string>
<string name="user_dialog_button_delete">Xóa người dùng</string>
<string name="user_dialog_button_delete">Xóa tài khoản</string>
<string name="user_dialog_button_cancel">Hủy</string>
<string name="add_dialog_use_another_server">Sử dụng máy chủ khác</string>
<string name="user_dialog_password_hint_edit">Mật khẩu (không đổi nếu để trống)</string>
<string name="add_dialog_title">Theo dõi chủ đề</string>
<string name="user_dialog_username_hint">Tên tài khoản</string>
<string name="channel_notifications_min_name">Ưu tiên thấp nhất</string>
<string name="channel_notifications_max_name">Ưu tiên cao nhất</string>
<string name="channel_notifications_min_name">Mức ưu tiên thấp nhất</string>
<string name="channel_notifications_max_name">Mức ưu tiên cao nhất</string>
<string name="channel_notifications_group_default_name">Mặc định</string>
<string name="channel_subscriber_service_name">Dịch vụ thông báo</string>
<string name="channel_notifications_default_name">Mặc định</string>
<string name="channel_notifications_high_name">Ưu tiên cao</string>
<string name="channel_notifications_high_name">Mức ưu tiên cao</string>
<string name="channel_subscriber_notification_title">Chờ thông báo</string>
<string name="refresh_message_result">Đã nhận %1$d thông báo</string>
<string name="main_action_mode_delete_dialog_permanently_delete">Xóa vĩnh viễn</string>
@ -32,11 +32,11 @@
<string name="add_dialog_button_subscribe">Đăng kí</string>
<string name="add_dialog_button_back">Quay lại</string>
<string name="add_dialog_button_login">Đăng nhập</string>
<string name="add_dialog_login_title">Yêu cầi đăng nhập</string>
<string name="detail_how_to_example">Ví dụ (với curl):<br></br><tt>$ curl -d \"Hi\" %1$s</tt></string>
<string name="add_dialog_login_title">Yêu cầu đăng nhập</string>
<string name="detail_how_to_example"><![CDATA[-Ví dụ (với curl):<br/><tt>$ curl -d \"Xin chào\" %1$s</tt>-]]></string>
<string name="add_dialog_error_connection_failed">Kết nối không thành công: %1$s</string>
<string name="detail_no_notifications_text">Bạn chưa nhận thông báo nào cho chủ đề này.</string>
<string name="detail_how_to_intro">Để gửi thông báo tới chủ đề này, hãy PUT hoặc POST vào URL của chủ đề.</string>
<string name="detail_how_to_intro">Để gửi thông báo tới chủ đề này, hãy PUT hoặc POST tới URL của chủ đề.</string>
<string name="main_banner_websocket_text">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 <a href="https://ntfy.sh/docs/config/#nginxapache2caddy">trong proxy </a>của bạn. Điều này có thể được bật/tắt trong Cài đặt.</string>
<string name="main_menu_settings_title">Cài đặt</string>
<string name="main_menu_report_bug_title">Báo lỗi</string>
@ -58,4 +58,288 @@
<string name="detail_action_mode_delete_dialog_cancel">Huỷ</string>
<string name="notification_dialog_cancel">Huỷ</string>
<string name="notification_popup_action_cancel">Huỷ</string>
<string name="channel_subscriber_notification_instant_text">Đã đăng kí nhận thông báo thời gian thực</string>
<string name="channel_subscriber_notification_instant_text_one">Đã đăng kí một chủ đề nhận thông báo thời gian thực</string>
<string name="channel_subscriber_notification_instant_text_two">Đã đăng kí hai chủ đề nhận thông báo thời gian thực</string>
<string name="channel_subscriber_notification_instant_text_three">Đã đăng kí ba chủ đề nhận thông báo thời gian thực</string>
<string name="channel_subscriber_notification_instant_text_four">Đã đăng kí bốn chủ đề nhận thông báo thời gian thực</string>
<string name="channel_subscriber_notification_instant_text_five">Đã đăng kí năm chủ đề nhận thông báo thời gian thực</string>
<string name="channel_subscriber_notification_instant_text_six">Đã đăng kí sáu chủ đề nhận thông báo thời gian thực</string>
<string name="channel_subscriber_notification_instant_text_more">Đã đăng kí %1$d chủ đề nhận thông báo thời gian thực</string>
<string name="channel_subscriber_notification_noinstant_text">Đã đăng kí các chủ đề</string>
<string name="channel_subscriber_notification_noinstant_text_one">Đã đăng kí một chủ đề</string>
<string name="channel_subscriber_notification_noinstant_text_two">Đã đăng kí hai chủ đề</string>
<string name="channel_subscriber_notification_noinstant_text_three">Đã đăng kí ba chủ đề</string>
<string name="channel_subscriber_notification_noinstant_text_four">Đã đăng kí bốn chủ đề</string>
<string name="channel_subscriber_notification_noinstant_text_five">Đã đăng kí năm chủ đề</string>
<string name="channel_subscriber_notification_noinstant_text_six">Đã đăng kí sáu chủ đề</string>
<string name="channel_subscriber_notification_noinstant_text_more">Đã đăng kí %1$d chủ đề</string>
<string name="refresh_message_no_results">Không có thông báo mới</string>
<string name="refresh_message_error">Không thể cập nhật %1$d chủ đề\n\n%2$s</string>
<string name="refresh_message_error_one">Không thể cập nhật chủ đề: %1$s</string>
<string name="main_menu_notifications_enabled">Hiện thông báo</string>
<string name="main_menu_notifications_disabled_forever">Đã tắt thông báo</string>
<string name="main_item_status_reconnecting">Đang kết nối lại …</string>
<string name="main_item_status_unified_push">%1$s (UnifiedPush)</string>
<string name="main_add_button_description">Đăng kí thêm</string>
<string name="main_how_to_intro">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.</string>
<string name="main_unified_push_toast">Chủ đề này được quản lí bởi %1$s thông qua UnifiedPush</string>
<string name="main_banner_battery_text">Khuyến nghị tắt chế độ tiết kiệm pin để tránh lỗi thông báo.</string>
<string name="main_banner_battery_button_dismiss">Bỏ qua</string>
<string name="main_banner_battery_button_fix_now">Cho phép</string>
<string name="main_banner_websocket_button_dismiss">Bỏ qua</string>
<string name="main_banner_websocket_button_enable_now">Cho phép</string>
<string name="main_banner_websocket_reconnect_text">Để đả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 &amp; Nhắc nhở cho ntfy</string>
<string name="main_banner_websocket_reconnect_button_remind_later">Hỏi sau</string>
<string name="main_banner_websocket_reconnect_button_dismiss">Bỏ qua</string>
<string name="main_banner_websocket_reconnect_button_enable_now">Cho phép</string>
<string name="add_dialog_description_below">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.</string>
<string name="add_dialog_use_another_server_description">Nhập URL bên dưới để đăng ký các chủ đề từ server khác.</string>
<string name="add_dialog_instant_delivery">Nhận thông báo thời gian thực trong chế độ ngủ</string>
<string name="add_dialog_instant_delivery_description">Đảm bảo thông báo được gửi ngay cả khi thiết bị không hoạt động.</string>
<string name="add_dialog_foreground_description">Thông báo thời gian thực luôn được bật cho hosts khác ngoài %1$s.</string>
<string name="add_dialog_base_urls_dropdown_choose">Chọn URL</string>
<string name="add_dialog_base_urls_dropdown_clear">Xóa URL</string>
<string name="detail_how_to_link">Hướng dẫn cụ thể trên ntfy.sh, và trong tài liệu hướng dẫn.</string>
<string name="detail_clear_dialog_message">Xóa tất cả thông báo trong chủ đề này?</string>
<string name="detail_clear_dialog_permanently_delete">Xóa vĩnh viễn</string>
<string name="detail_delete_dialog_message">Hủy đăng kí chủ đề và xóa tát cả thông báo đã nhận?</string>
<string name="detail_delete_dialog_permanently_delete">Xóa vĩnh viễn</string>
<string name="detail_test_title">Thử nghiệm: Bạn có thể đặt title nếu thích.</string>
<string name="detail_test_message">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.</string>
<string name="detail_test_message_error">Không thể gửi thông báo: %1$s</string>
<string name="detail_test_message_error_unauthorized_anon">Không thể gửi thông báo: Gửi ẩn danh không được phép.</string>
<string name="detail_test_message_error_unauthorized_user">Không thể gửi thông báo: Người dùng \"%1$s\" không có quyền.</string>
<string name="detail_test_message_error_too_large">Không thể gửi thông báo: Tệp đính kèm quá lớn.</string>
<string name="detail_copied_to_clipboard_message">Đã lưu vào clipboard</string>
<string name="detail_instant_delivery_enabled">Hiện thông báo thời gian thực</string>
<string name="detail_instant_delivery_disabled">Đã tắt thông báo thời gian thực</string>
<string name="detail_deep_link_subscribed_toast_message">Đã đăng kí chủ đề %1$s</string>
<string name="detail_item_snack_deleted">Đã xóa thông báo</string>
<string name="detail_item_snack_undo">Hoàn tác</string>
<string name="detail_item_menu_open">Mở tệp</string>
<string name="detail_item_menu_delete">Xóa tệp</string>
<string name="detail_item_menu_download">Tải tệp</string>
<string name="detail_item_menu_cancel">Hủy tải xuống</string>
<string name="detail_item_menu_save_file">Lưu tệp</string>
<string name="detail_item_menu_copy_url">Sao chép URL</string>
<string name="detail_item_menu_copy_url_copied">URL đã được sao chép vào clipboard</string>
<string name="detail_item_menu_copy_contents">Sao chép thông báo</string>
<string name="detail_item_menu_copy_contents_copied">Thông báo đã được sao chép vào clipboard</string>
<string name="detail_item_saved_successfully">Đã lưu thành \"%1$s\" trong thư mục \"Downloads\"</string>
<string name="detail_item_cannot_download">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.</string>
<string name="detail_item_cannot_open">Không thể mở tệp đính kèm: %1$s</string>
<string name="detail_item_cannot_open_not_found">Không thể mở tệp: Tệp đã xóa hoặc không có ứng dụng mở được.</string>
<string name="detail_item_cannot_open_url">Không thể mở liên kết: %1$s</string>
<string name="detail_item_cannot_open_apk">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.</string>
<string name="detail_item_cannot_save">Không thể lưu tệp đính kèm: %1$s</string>
<string name="detail_item_cannot_delete">Không thể xóa tệp đính kèm: %1$s</string>
<string name="detail_item_download_failed">Không thể tải tệp đính kèm: %1$s</string>
<string name="detail_item_download_info_not_downloaded">chưa tải xuống</string>
<string name="detail_item_download_info_not_downloaded_expired">chưa tải xuống, liên kết hết hạn</string>
<string name="detail_item_download_info_not_downloaded_expires_x">chưa tải xuống, hết hạn %1$s</string>
<string name="detail_item_download_info_downloading_x_percent">Đã tải %1$d%%</string>
<string name="detail_item_download_info_deleted">đã xóa</string>
<string name="detail_item_download_info_deleted_expired">đã xóa, liên kết hết hạn</string>
<string name="detail_item_download_info_deleted_expires_x">đã xóa, liên kết hết hạn %1$s</string>
<string name="detail_item_download_info_download_failed">tải xuống thất bại</string>
<string name="detail_item_download_info_download_failed_expired">tải xuống thất bại, liên kết hết hạn</string>
<string name="detail_item_download_info_download_failed_expires_x">tải xuống thất bại, liên kết hết hạn %1$s</string>
<string name="detail_menu_notifications_enabled">Hiện thông báo</string>
<string name="detail_menu_notifications_disabled_forever">Đã tắt thông báo</string>
<string name="detail_menu_notifications_disabled_until">Thông báo bị tắt cho đến %1$s</string>
<string name="detail_menu_enable_instant">Bật thông báo thời gian thực</string>
<string name="detail_menu_disable_instant">Tắt thông báo thời gian thực</string>
<string name="detail_menu_test">Gửi thông báo thử nghiệm</string>
<string name="detail_menu_copy_url">Sao chép địa chỉ chủ đề</string>
<string name="detail_menu_clear">Xóa tất cả thông báo</string>
<string name="detail_menu_settings">Cài đặt đăng kí</string>
<string name="detail_menu_unsubscribe">Hủy đăng kí</string>
<string name="detail_action_mode_menu_copy">Sao chép</string>
<string name="detail_action_mode_menu_delete">Xóa</string>
<string name="detail_action_mode_delete_dialog_message">Xóa vĩnh viễn thông báo đã chọn?</string>
<string name="detail_action_mode_delete_dialog_permanently_delete">Xóa vĩnh viễn</string>
<string name="detail_settings_title">Cài đặt đăng kí</string>
<string name="share_title">Chia sẻ</string>
<string name="share_menu_send">Chia sẻ</string>
<string name="share_content_title">Xem trước thông báo</string>
<string name="share_content_text_hint">Thêm nội dung để chia sẻ</string>
<string name="share_content_image_text">Một ảnh đã được chia sẻ với bạn</string>
<string name="share_content_image_error">Không thể đọc ảnh: %1$s</string>
<string name="share_content_file_text">Một tệp đã được chia sẻ với bạn</string>
<string name="share_content_file_error">Không thể đọc thông tin tệp: %1$s</string>
<string name="share_topic_title">Chia sẻ tới</string>
<string name="share_suggested_topics">Các chủ đề được gợi ý</string>
<string name="share_successful">Đã chia sẻ thành công</string>
<string name="notification_dialog_title">Tắt thông báo</string>
<string name="notification_dialog_enabled_toast_message">Thông báo bật lại</string>
<string name="notification_dialog_muted_forever_toast_message">Đã tắt thông báo</string>
<string name="notification_dialog_muted_until_toast_message">Thông báo bị tắt cho đến %1$s</string>
<string name="notification_dialog_show_all">Xem tất cả thông báo</string>
<string name="notification_dialog_30min">30 phút</string>
<string name="notification_dialog_1h">1 tiếng</string>
<string name="notification_dialog_2h">2 tiếng</string>
<string name="notification_dialog_8h">8 tiếng</string>
<string name="notification_dialog_tomorrow">Ngày mai</string>
<string name="notification_dialog_forever">Cho đến khi bật lại</string>
<string name="notification_popup_action_open">Mở</string>
<string name="notification_popup_action_browse">Duyệt</string>
<string name="notification_popup_action_download">Tải</string>
<string name="notification_popup_file">%1$s\nTệp: %2$s</string>
<string name="notification_popup_file_downloading">Đang tải %1$s, %2$d%%\n%3$s</string>
<string name="notification_popup_file_download_successful">%1$s\nTệp: %2$s đã tải xong</string>
<string name="notification_popup_file_download_failed">%1$s\nTệp: %2$s, tải thất bại</string>
<string name="notification_popup_user_action_failed">%1$s thất bại: %2$s</string>
<string name="settings_title">Cài đặt</string>
<string name="settings_notifications_header">Thông báo</string>
<string name="settings_notifications_muted_until_title">Tắt thông báp</string>
<string name="settings_notifications_muted_until_show_all">Đang hiển thị tất cả thông báo</string>
<string name="settings_notifications_muted_until_forever">Đã tắt thông báo đến khi bật lại</string>
<string name="settings_notifications_muted_until_x">Thông báo bị tắt cho đến %1$s</string>
<string name="settings_notifications_min_priority_title">Mức ưu tiên thấp nhất</string>
<string name="settings_notifications_min_priority_summary_any">Đang hiển thị tất cả thông báo</string>
<string name="settings_notifications_min_priority_summary_x_or_higher">Hiển thị thông báo nếu mức ưu tiên là %1$d (%2$s) hoặc cao hơn</string>
<string name="settings_notifications_min_priority_summary_max">Hiển thị thông báo nếu mức ưu tiên 5 (cao nhất)</string>
<string name="settings_notifications_min_priority_min">Mọi mức ưu tiên</string>
<string name="settings_notifications_min_priority_low">Mức ưu tiên thấp trở lên</string>
<string name="settings_notifications_min_priority_default">Mức mặc định trở lên</string>
<string name="settings_notifications_min_priority_high">Mức ưu tiên cao trở lên</string>
<string name="settings_notifications_min_priority_max">Chỉ mức ưu tiên cao nhất</string>
<string name="settings_notifications_priority_low">thấp</string>
<string name="settings_notifications_priority_min">thấp nhất</string>
<string name="settings_notifications_priority_default">mặc định</string>
<string name="settings_notifications_priority_high">cao</string>
<string name="settings_notifications_priority_max">cao nhất</string>
<string name="settings_notifications_channel_prefs_title">cài đặt kênh</string>
<string name="settings_notifications_channel_prefs_summary">Bỏ qua chế độ Không làm phiền (DND), âm thanh, v.v.</string>
<string name="settings_notifications_auto_download_title">Tải tệp đính kèm</string>
<string name="settings_notifications_auto_download_summary_always">Tự động tải tệp đính kèm</string>
<string name="settings_notifications_auto_download_summary_never">Không tự động tải tệp đính kèm</string>
<string name="settings_notifications_auto_download_summary_smaller_than_x">Tự động tải tệp đính kèm tối đa %1$s</string>
<string name="settings_notifications_auto_download_never">Không tự động tải</string>
<string name="settings_notifications_auto_download_always">Tự động tải</string>
<string name="settings_notifications_auto_download_100k">nếu dưới 100kB</string>
<string name="settings_notifications_auto_download_500k">nếu dưới 500 kB</string>
<string name="settings_notifications_auto_download_1m">nếu dưới 1 MB</string>
<string name="settings_notifications_auto_download_5m">nếu dưới 5 MB</string>
<string name="settings_notifications_auto_download_10m">nếu dưới 10 MB</string>
<string name="settings_notifications_auto_download_50m">nếu dưới 50 MB</string>
<string name="settings_notifications_auto_delete_title">Xóa thông báo</string>
<string name="settings_notifications_auto_delete_summary_never">Không tự động xóa thông báo</string>
<string name="settings_notifications_auto_delete_summary_one_day">Tự động xóa thông báo sau một ngày</string>
<string name="settings_notifications_auto_delete_summary_three_days">Tự động xóa thông báo sau ba ngày</string>
<string name="settings_notifications_auto_delete_summary_one_week">Tự động xóa thông báo sau một tuần</string>
<string name="settings_notifications_auto_delete_summary_one_month">Tự động xóa thông báo sau một tháng</string>
<string name="settings_notifications_auto_delete_summary_three_months">Tự động xóa thông báo sau ba tháng</string>
<string name="settings_notifications_auto_delete_never">Không bao giờ</string>
<string name="settings_notifications_auto_delete_one_day">Sau một ngày</string>
<string name="settings_notifications_auto_delete_three_days">Sau ba ngày</string>
<string name="settings_notifications_auto_delete_one_week">Sau một tuần</string>
<string name="settings_notifications_auto_delete_one_month">Sau một tháng</string>
<string name="settings_notifications_auto_delete_three_months">Sau ba tháng</string>
<string name="settings_notifications_insistent_max_priority_title">Cảnh báo mức ưu tiên cao nhất</string>
<string name="settings_notifications_insistent_max_priority_summary_enabled">Cảnh báo liên tục cho thông báo ưu tiên cao nhất cho đén khi tắt</string>
<string name="settings_notifications_insistent_max_priority_summary_disabled">Cảnh báo một lần cho thông báo ưu tiên cao nhất</string>
<string name="settings_general_header">Tổng quan</string>
<string name="settings_general_default_base_url_title">Server mặc định</string>
<string name="settings_general_default_base_url_message">Nhập URL server để dùng làm mặc định khi đăng ký/chia sẻ chủ đề mới.</string>
<string name="settings_general_default_base_url_default_summary">%1$s (mặc định)</string>
<string name="settings_general_users_title">Quản lí tài khoản</string>
<string name="settings_general_users_summary">Thêm/xóa tài khoản cho các topic được bảo vệ</string>
<string name="settings_general_users_prefs_title">Tài khoản</string>
<string name="settings_general_users_prefs_user_not_used">Hiện chưa có topic sử dụng</string>
<string name="settings_general_users_prefs_user_used_by_one">Topic %1$s đang sử dụng</string>
<string name="settings_general_users_prefs_user_used_by_many">Các topic %1$s đang sử dụng</string>
<string name="settings_general_users_prefs_user_add">Thêm tài khoản</string>
<string name="settings_general_users_prefs_user_add_title">Thêm tài khoản mới</string>
<string name="settings_general_users_prefs_user_add_summary">Tạo tài khoản cho server mới</string>
<string name="settings_general_dark_mode_title">Chế độ tối</string>
<string name="settings_general_dark_mode_summary_system">Mặc định hệ thống</string>
<string name="settings_general_dark_mode_summary_light">Chế độ sáng</string>
<string name="settings_general_dark_mode_summary_dark">Đã bật chế độ tối. Sợ ánh sáng hả?</string>
<string name="settings_general_dark_mode_entry_system">Mặc định hệ thống</string>
<string name="settings_general_dark_mode_entry_light">Chế độ sáng</string>
<string name="settings_general_dark_mode_entry_dark">Chế độ tối</string>
<string name="settings_general_dynamic_colors_title">Màu động</string>
<string name="settings_general_dynamic_colors_summary_enabled">Màu động hệ thống</string>
<string name="settings_general_dynamic_colors_summary_disabled">Màu ntfy</string>
<string name="settings_backup_restore_header">Sao lưu &amp; Phục hồi</string>
<string name="settings_backup_restore_backup_title">Sao lưu vào tệp</string>
<string name="settings_backup_restore_backup_summary">Xuất cấu hình, thông báo và người dùng</string>
<string name="settings_backup_restore_backup_entry_everything">Tất cả</string>
<string name="settings_backup_restore_backup_entry_everything_no_users">Tất cả, trừ tài khoản</string>
<string name="settings_backup_restore_backup_entry_settings_only">Chỉ cài đặt</string>
<string name="settings_backup_restore_backup_successful">Bản sao lưu đã được tạo</string>
<string name="settings_backup_restore_backup_failed">Sao lưu thất bại: %1$s</string>
<string name="settings_backup_restore_restore_title">Phục hồi từ tệp</string>
<string name="settings_backup_restore_restore_summary">Nhập cấu hình, thông báo và tài khoản</string>
<string name="settings_backup_restore_restore_successful">Phục hồi thành công</string>
<string name="settings_backup_restore_restore_failed">Phục hồi thất bại: %1$s</string>
<string name="settings_advanced_header">Nâng cao</string>
<string name="settings_advanced_broadcast_title">Broadcast thông báo</string>
<string name="settings_advanced_broadcast_summary_enabled">Ứng dụng có thể nhận thông báo dưới dạng broadcast</string>
<string name="settings_advanced_broadcast_summary_disabled">Ứng dụng không thể nhận thông báo dưới dạng broadcast</string>
<string name="settings_advanced_unifiedpush_title">Bật UnifiedPush</string>
<string name="settings_advanced_unifiedpush_summary_enabled">ntfy sẽ gửi thông báo thông qua UnifiedPush</string>
<string name="settings_advanced_unifiedpush_summary_disabled">ntfy sẽ không gửi thông báo thông qua UnifiedPush</string>
<string name="settings_advanced_record_logs_title">Ghi logs</string>
<string name="settings_advanced_record_logs_summary_enabled">Ghi logs (tối đa 1.000 đầu mục) vào thiết bị…</string>
<string name="settings_advanced_record_logs_summary_disabled">Bật ghi log để chia sẻ log để chẩn đoán sự cố sau này.</string>
<string name="settings_advanced_export_logs_title">Sao chép/tải lên logs</string>
<string name="settings_advanced_export_logs_summary">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.</string>
<string name="settings_advanced_export_logs_entry_copy_original">Sao chép vào clipboard</string>
<string name="settings_advanced_export_logs_entry_copy_scrubbed">Sao chép vào clipboard (có che)</string>
<string name="settings_advanced_export_logs_entry_upload_original">Tải lên và sao chép liên kết</string>
<string name="settings_advanced_export_logs_entry_upload_scrubbed">Tải lên và sao chép liên kết (có che)</string>
<string name="settings_advanced_export_logs_copied_logs">Logs đã được sao chép vào clipboard</string>
<string name="settings_advanced_export_logs_uploading">Đang tải lên logs …</string>
<string name="settings_advanced_export_logs_copied_url">Log đã được tải lên và URL đã sao chép</string>
<string name="settings_advanced_export_logs_error_uploading">Không thể tải lên log: %1$s</string>
<string name="settings_advanced_export_logs_scrub_dialog_text">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.</string>
<string name="settings_advanced_export_logs_scrub_dialog_empty">Không có chủ đề/hostname nào bị ẩn. Có thể bạn chưa đăng ký chủ đề nào?</string>
<string name="settings_advanced_clear_logs_title">Xóa logs</string>
<string name="settings_advanced_clear_logs_summary">Xóa log cũ và bắt đầu lại</string>
<string name="settings_advanced_clear_logs_deleted_toast">Đã xóa logs</string>
<string name="settings_advanced_connection_protocol_title">Giao thức kết nối</string>
<string name="settings_advanced_connection_protocol_summary_jsonhttp">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.</string>
<string name="settings_advanced_connection_protocol_summary_ws">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.</string>
<string name="settings_advanced_connection_protocol_entry_jsonhttp">JSON stream qua HTTP</string>
<string name="settings_advanced_exact_alarms_title">Báo thức chính xác</string>
<string name="settings_advanced_exact_alarms_true">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.</string>
<string name="settings_advanced_exact_alarms_false">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.</string>
<string name="settings_about_version_title">Phiên bản</string>
<string name="settings_about_version_copied_to_clipboard_message">Đã lưu vào clipboard</string>
<string name="detail_settings_notifications_instant_title">Thông báo thời gian thực</string>
<string name="detail_settings_notifications_instant_summary_on">Thông báo được gửi ngay lập tức. Cần foreground service và tốn pin hơn.</string>
<string name="detail_settings_notifications_instant_summary_off">Thông báo được gửi qua Firebase. Có thể trễ, nhưng tiết kiệm pin hơn.</string>
<string name="detail_settings_notifications_dedicated_channels_title">Cài đặt thông báo tùy chỉnh</string>
<string name="detail_settings_notifications_dedicated_channels_summary_on">Đang sử dụng cài đặt tùy chỉnh cho chủ đề này</string>
<string name="detail_settings_notifications_dedicated_channels_summary_off">Đang sử dụng cài đặt mặc định (âm thanh, bỏ qua Không làm phiền, v.v.)</string>
<string name="detail_settings_notifications_open_channels_title">Tùy chỉnh cài đặt thông báo</string>
<string name="detail_settings_notifications_open_channels_summary">Bỏ qua Không làm phiền (DND), âm thanh, etc.</string>
<string name="detail_settings_notifications_insistent_max_priority_list_item_enabled">Cảnh báo liên tục</string>
<string name="detail_settings_notifications_insistent_max_priority_list_item_disabled">Cảnh báo một lần</string>
<string name="detail_settings_appearance_header">Giao diện</string>
<string name="detail_settings_appearance_icon_set_title">Biểu tượng chủ đề</string>
<string name="detail_settings_appearance_icon_set_summary">Chọn biểu tượng hiển thị trong thông báo</string>
<string name="detail_settings_appearance_icon_remove_title">Biểu tượng chủ đề (Nhấn để xóa)</string>
<string name="detail_settings_appearance_icon_remove_summary">Hiển thị biểu tượng trong thông báo của topic này</string>
<string name="detail_settings_appearance_icon_error_saving">Không thể lưu biểu tượng: %1$s</string>
<string name="detail_settings_appearance_display_name_title">Tên hiển thị</string>
<string name="detail_settings_appearance_display_name_message">Đặt tên hiển thị cho subscription này. Để trống để sử dụng tên mặc định (%1$s).</string>
<string name="detail_settings_appearance_display_name_default_summary">%1$s (mặc định)</string>
<string name="detail_settings_global_setting_title">Sử dụng cài đặt chung</string>
<string name="detail_settings_global_setting_suffix">đang sử dụng cài đặt chung</string>
<string name="detail_settings_about_topic_url_title">URL chủ đề</string>
<string name="detail_settings_about_topic_url_copied_to_clipboard_message">Đã lưu vào clipboard</string>
<string name="user_dialog_title_add">Thêm tài khoản</string>
<string name="user_dialog_title_edit">Chỉnh sửa tài khoản</string>
<string name="user_dialog_description_add">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.</string>
<string name="user_dialog_base_url_hint">URL dịch vụ</string>
<string name="settings_advanced_connection_protocol_entry_ws">WebSockets</string>
<string name="settings_about_header">About</string>
<string name="settings_about_version_format">ntfy %1$s (%2$s)</string>
<string name="detail_settings_about_header">About</string>
<string name="detail_item_tags">Tags: %1$s</string>
<string name="settings_advanced_export_logs_scrub_dialog_button_ok">OK</string>
</resources>

View file

@ -349,4 +349,7 @@
<string name="settings_advanced_exact_alarms_title">精确闹钟</string>
<string name="settings_advanced_exact_alarms_true">ntfy 可以安排精确闹钟。在后台重连 WebSockets 需要精确闹钟权限。单击撤销该权限。</string>
<string name="settings_advanced_exact_alarms_false">ntfy 无法安排精确闹钟。在后台重连 WebSockets 需要精确闹钟权限。单击授予该权限。</string>
<string name="settings_general_dynamic_colors_title">动态颜色</string>
<string name="settings_general_dynamic_colors_summary_enabled">使用动态系统颜色</string>
<string name="settings_general_dynamic_colors_summary_disabled">使用 ntfy 主题色</string>
</resources>

View file

@ -349,4 +349,7 @@
<string name="settings_advanced_exact_alarms_title">精準提醒</string>
<string name="settings_advanced_exact_alarms_true">ntfy 可以排程精準提醒。精準提醒是讓 WebSocket 能在背景重新連線的必要條件。點擊以撤銷此權限。</string>
<string name="settings_advanced_exact_alarms_false">ntfy 無法排程精準提醒。精準提醒是讓 WebSocket 能在背景重新連線的必要條件。點擊以授予此權限。</string>
<string name="settings_general_dynamic_colors_title">動態色彩</string>
<string name="settings_general_dynamic_colors_summary_enabled">使用系統動態色彩</string>
<string name="settings_general_dynamic_colors_summary_disabled">使用 ntfy 主題色彩</string>
</resources>

View file

@ -145,6 +145,10 @@
<color name="action_bar">#338574</color> <!-- md_theme_primary (light) -->
<color name="detail_activity_background">#EEEEEE</color> <!-- Light gray for detail activity in light mode -->
<!-- Chip colors -->
<color name="chip_background_color">#E8E8E8</color> <!-- Light gray background -->
<color name="chip_background_checked">#BDBDBD</color> <!-- Darker when selected -->
<!-- Remove black status bar in Action mode: https://stackoverflow.com/a/79456725 -->
<color name="abc_decor_view_status_guard" tools:override="true">@android:color/transparent</color>
<color name="abc_decor_view_status_guard_light" tools:override="true">@android:color/transparent</color>

View file

@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<dimen name="fab_margin">24dp</dimen>
</resources>

View file

@ -199,6 +199,58 @@
<string name="detail_settings_title">Subscription settings</string>
<!-- ... -->
<!-- Publish dialog -->
<string name="publish_dialog_title">Publish to %1$s</string>
<string name="publish_dialog_title_hint">Title</string>
<string name="publish_dialog_title_placeholder">e.g. Someone is at the door</string>
<string name="publish_dialog_message_hint">Message</string>
<string name="publish_dialog_message_placeholder">@string/message_bar_hint</string>
<string name="publish_dialog_tags_hint">Tags</string>
<string name="publish_dialog_tags_placeholder">e.g. warning, skull</string>
<string name="publish_dialog_priority_hint">Priority</string>
<string name="publish_dialog_priority_default">@string/channel_notifications_default_name</string>
<string name="publish_dialog_priority_min">@string/channel_notifications_min_name</string>
<string name="publish_dialog_priority_low">@string/channel_notifications_low_name</string>
<string name="publish_dialog_priority_high">@string/channel_notifications_high_name</string>
<string name="publish_dialog_priority_max">@string/channel_notifications_max_name</string>
<string name="publish_dialog_button_publish">Publish</string>
<string name="publish_dialog_error_sending">Cannot publish message: %1$s</string>
<string name="publish_dialog_error_server">Cannot publish message: %1$s (code %2$d)</string>
<string name="publish_dialog_message_published">Message published</string>
<string name="publish_dialog_uploading">Uploading: %1$s (%2$s / %3$s)</string>
<string name="publish_dialog_upload_cancelled">Upload cancelled</string>
<string name="publish_dialog_chip_title">Title</string>
<string name="publish_dialog_chip_tags">Tags</string>
<string name="publish_dialog_chip_priority">Priority</string>
<string name="publish_dialog_chip_click_url">Click URL</string>
<string name="publish_dialog_chip_email">Email</string>
<string name="publish_dialog_chip_delay">Delay</string>
<string name="publish_dialog_chip_markdown">Markdown</string>
<string name="publish_dialog_chip_attach_url">Attach by URL</string>
<string name="publish_dialog_chip_attach_file">Attach local file</string>
<string name="publish_dialog_chip_phone_call">Phone call</string>
<string name="publish_dialog_click_url_hint">Click URL</string>
<string name="publish_dialog_click_url_placeholder">e.g. https://example.com/alerts/1234</string>
<string name="publish_dialog_email_hint">Email</string>
<string name="publish_dialog_email_placeholder">e.g. phil@example.com</string>
<string name="publish_dialog_delay_hint">Delay delivery</string>
<string name="publish_dialog_delay_placeholder">e.g. 30m, 1h, today 9pm (English only)</string>
<string name="publish_dialog_attach_url_hint">Attachment URL</string>
<string name="publish_dialog_attach_url_placeholder">e.g. https://example.com/flowers.jpg</string>
<string name="publish_dialog_attach_filename_hint">Attachment filename</string>
<string name="publish_dialog_attach_filename_placeholder">e.g. lilies.jpg</string>
<string name="publish_dialog_phone_call_hint">Phone call</string>
<string name="publish_dialog_phone_call_placeholder">e.g. +1234567890</string>
<string name="publish_dialog_docs_text">For examples and a detailed description of all publish features, please refer to the <a href="https://docs.ntfy.sh/publish/">documentation</a>.</string>
<!-- Message bar -->
<string name="message_bar_hint">Type a message here</string>
<string name="message_bar_publish_button_description">Publish message</string>
<string name="message_bar_expand_button_description">More options</string>
<!-- Detail activity: Publish FAB -->
<string name="detail_fab_publish_description">Publish notification</string>
<!-- Share activity -->
<string name="share_title">Share</string>
<string name="share_menu_send">Share</string>
@ -309,9 +361,15 @@
<string name="settings_general_dark_mode_entry_system">Use system default</string>
<string name="settings_general_dark_mode_entry_light">Light mode</string>
<string name="settings_general_dark_mode_entry_dark">Dark mode</string>
<string name="settings_general_language_title">Language</string>
<string name="settings_general_language_summary_system">Using system default</string>
<string name="settings_general_language_system_default">System default</string>
<string name="settings_general_dynamic_colors_title">Dynamic colors</string>
<string name="settings_general_dynamic_colors_summary_enabled">Using the dynamic system colors</string>
<string name="settings_general_dynamic_colors_summary_disabled">Using the ntfy theme colors</string>
<string name="settings_general_message_bar_title">Show message bar</string>
<string name="settings_general_message_bar_summary_enabled">Message bar shown at bottom of topic view</string>
<string name="settings_general_message_bar_summary_disabled">Publish button shown at bottom of topic view</string>
<string name="settings_backup_restore_header">Backup &amp; Restore</string>
<string name="settings_backup_restore_backup_title">Back up to file</string>
<string name="settings_backup_restore_backup_summary">Export config, notifications, and users</string>

View file

@ -102,7 +102,7 @@
<style name="BannerShapeAppearance">
<item name="cornerFamily">rounded</item>
<item name="cornerSize">8dp</item>
<item name="cornerSize">0dp</item>
</style>
<!-- Full-screen dialog style for Material 3 compliance -->
@ -111,6 +111,7 @@
<item name="android:windowIsFloating">false</item>
<item name="android:statusBarColor">?attr/colorSurface</item>
<item name="android:windowBackground">?attr/colorSurface</item>
<item name="android:windowLightStatusBar">true</item>
<item name="android:windowAnimationStyle">@style/Animation.App.FullScreenDialog</item>
</style>

View file

@ -22,7 +22,9 @@
<string name="settings_general_default_base_url_key" translatable="false">DefaultBaseURL</string>
<string name="settings_general_users_key" translatable="false">ManageUsers</string>
<string name="settings_general_dark_mode_key" translatable="false">DarkMode</string>
<string name="settings_general_language_key" translatable="false">Language</string>
<string name="settings_general_dynamic_colors_key" translatable="false">DynamicColors</string>
<string name="settings_general_message_bar_key" translatable="false">MessageBarEnabled</string>
<string name="settings_backup_restore_backup_key" translatable="false">Backup</string>
<string name="settings_backup_restore_restore_key" translatable="false">Restore</string>
<string name="settings_advanced_broadcast_key" translatable="false">BroadcastEnabled</string>

View file

@ -0,0 +1,43 @@
<?xml version="1.0" encoding="utf-8"?>
<locale-config xmlns:android="http://schemas.android.com/apk/res/android">
<!--
This file only lists 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 supportedLocales list
in the SettingsActivity.
-->
<locale android:name="en"/> <!-- English (default) -->
<locale android:name="bg"/> <!-- Bulgarian -->
<locale android:name="ca"/> <!-- Catalan -->
<locale android:name="cs"/> <!-- Czech -->
<locale android:name="de"/> <!-- German -->
<locale android:name="es"/> <!-- Spanish -->
<locale android:name="et"/> <!-- Estonian -->
<locale android:name="fi"/> <!-- Finnish -->
<locale android:name="fr"/> <!-- French -->
<locale android:name="gl"/> <!-- Galician -->
<locale android:name="in"/> <!-- Indonesian -->
<locale android:name="it"/> <!-- Italian -->
<locale android:name="iw"/> <!-- Hebrew -->
<locale android:name="ja"/> <!-- Japanese -->
<locale android:name="ko"/> <!-- Korean -->
<locale android:name="nb-NO"/> <!-- Norwegian Bokmål -->
<locale android:name="nl"/> <!-- Dutch -->
<locale android:name="pl"/> <!-- Polish -->
<locale android:name="pt"/> <!-- Portuguese -->
<locale android:name="pt-BR"/> <!-- Portuguese (Brazil) -->
<locale android:name="ro"/> <!-- Romanian -->
<locale android:name="ru"/> <!-- Russian -->
<locale android:name="sk"/> <!-- Slovak -->
<locale android:name="sv"/> <!-- Swedish -->
<locale android:name="ta"/> <!-- Tamil -->
<locale android:name="tr"/> <!-- Turkish -->
<locale android:name="uk"/> <!-- Ukrainian -->
<locale android:name="uz"/> <!-- Uzbek -->
<locale android:name="vi"/> <!-- Vietnamese -->
<locale android:name="zh-CN"/> <!-- Chinese (Simplified) -->
<locale android:name="zh-TW"/> <!-- Chinese (Traditional) -->
</locale-config>

View file

@ -54,10 +54,17 @@
app:entries="@array/settings_general_dark_mode_entries"
app:entryValues="@array/settings_general_dark_mode_values"
app:defaultValue="-1"/>
<ListPreference
app:key="@string/settings_general_language_key"
app:title="@string/settings_general_language_title"/>
<SwitchPreferenceCompat
app:key="@string/settings_general_dynamic_colors_key"
app:title="@string/settings_general_dynamic_colors_title"
app:isPreferenceVisible="false"/>
<SwitchPreferenceCompat
app:key="@string/settings_general_message_bar_key"
app:title="@string/settings_general_message_bar_title"
app:defaultValue="true"/>
</PreferenceCategory>
<PreferenceCategory app:title="@string/settings_backup_restore_header">
<ListPreference

View file

@ -1,13 +1,9 @@
buildscript {
ext.kotlin_version = '2.0.21'
repositories {
google()
mavenCentral()
}
ext.kotlin_version = '2.2.0'
dependencies {
classpath 'com.android.tools.build:gradle:8.13.0'
classpath 'com.android.tools.build:gradle:8.13.2'
classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version"
classpath 'com.google.gms:google-services:4.3.15' // This is removed in the "fdroid" flavor
classpath 'com.google.gms:google-services:4.4.4' // This is removed in the "fdroid" flavor
// NOTE: Do not place your application dependencies here; they belong
// in the individual module build.gradle files
@ -15,17 +11,5 @@ buildscript {
}
plugins {
id 'com.google.devtools.ksp' version '2.0.21-1.0.27'
id 'com.google.devtools.ksp' version '2.2.0-2.0.2'
}
allprojects {
repositories {
google()
mavenCentral()
maven { url "https://jitpack.io" } // For StfalconImageViewer
}
}
task clean(type: Delete) {
delete rootProject.buildDir
}

View file

@ -0,0 +1,5 @@
Maintenance + bug fixes:
* Updated dependencies, minimum SDK version to 26, clean up legacy code, upgrade Gradle (ntfy-android#140, thanks to @cyb3rko)
* Updated target SDK version to 36
* Fixed crashes with redrawing the list when temporarily muted topics expire
* Fixed ForegroundServiceDidNotStartInTimeException (#1520)

View file

@ -0,0 +1,6 @@
Features:
* Allow publishing messages through the message bar and publish dialog (#98, ntfy-android#144)
* Language selector to allow overriding the system language (#1508, ntfy-android#145, thanks to @hudsonm62 for reporting)
Maintenance + bug fixes:
* Add support for (technically incorrect) 'image/jpg' MIME type (ntfy-android#142, thanks to @Murilobeluco)

View file

@ -1 +1 @@
Gửi thông báo đến điện thoại của bạn từ bất kỳ script nào bằng yêu cầu PUT/POST
Gửi thông báo đến điện thoại từ bất kỳ script nào bằng yêu cầu PUT/POST

View file

@ -1 +1 @@
ntfy - PUT/POST đến điện thoại của bạn
ntfy - PUT/POST đến điện thoại

Binary file not shown.

View file

@ -1,6 +1,7 @@
#Sat Feb 22 14:52:01 MST 2025
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-8.13-bin.zip
distributionUrl=https\://services.gradle.org/distributions/gradle-9.2.1-bin.zip
networkTimeout=10000
validateDistributionUrl=true
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists

310
gradlew vendored
View file

@ -1,78 +1,128 @@
#!/usr/bin/env sh
#!/bin/sh
#
# Copyright © 2015 the original authors.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# https://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
#
# SPDX-License-Identifier: Apache-2.0
#
##############################################################################
##
## Gradle start up script for UN*X
##
#
# Gradle start up script for POSIX generated by Gradle.
#
# Important for running:
#
# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is
# noncompliant, but you have some other compliant shell such as ksh or
# bash, then to run this script, type that shell name before the whole
# command line, like:
#
# ksh Gradle
#
# Busybox and similar reduced shells will NOT work, because this script
# requires all of these POSIX shell features:
# * functions;
# * expansions «$var», «${var}», «${var:-default}», «${var+SET}»,
# «${var#prefix}», «${var%suffix}», and «$( cmd )»;
# * compound commands having a testable exit status, especially «case»;
# * various built-in commands including «command», «set», and «ulimit».
#
# Important for patching:
#
# (2) This script targets any POSIX shell, so it avoids extensions provided
# by Bash, Ksh, etc; in particular arrays are avoided.
#
# The "traditional" practice of packing multiple parameters into a
# space-separated string is a well documented source of bugs and security
# problems, so this is (mostly) avoided, by progressively accumulating
# options in "$@", and eventually passing that to Java.
#
# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS,
# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly;
# see the in-line comments for details.
#
# There are tweaks for specific operating systems such as AIX, CygWin,
# Darwin, MinGW, and NonStop.
#
# (3) This script is generated from the Groovy template
# https://github.com/gradle/gradle/blob/HEAD/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt
# within the Gradle project.
#
# You can find Gradle at https://github.com/gradle/gradle/.
#
##############################################################################
# Attempt to set APP_HOME
# Resolve links: $0 may be a link
PRG="$0"
# Need this for relative symlinks.
while [ -h "$PRG" ] ; do
ls=`ls -ld "$PRG"`
link=`expr "$ls" : '.*-> \(.*\)$'`
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" "$@"

79
gradlew.bat vendored
View file

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

View file

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