Resolve conflicts
This commit is contained in:
parent
678747e783
commit
c6f1b31855
77 changed files with 2933 additions and 580 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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})"
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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!!)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 {
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -103,7 +103,7 @@ class CustomHeaderFragment : DialogFragment() {
|
|||
if (header != null) {
|
||||
dialog
|
||||
.getButton(AlertDialog.BUTTON_NEUTRAL)
|
||||
.dangerButton(requireContext())
|
||||
.dangerButton()
|
||||
}
|
||||
|
||||
// Validate input when typing
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
58
app/src/main/java/io/heckel/ntfy/ui/PriorityAdapter.kt
Normal file
58
app/src/main/java/io/heckel/ntfy/ui/PriorityAdapter.kt
Normal 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)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
671
app/src/main/java/io/heckel/ntfy/ui/PublishFragment.kt
Normal file
671
app/src/main/java/io/heckel/ntfy/ui/PublishFragment.kt
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
|
|
@ -102,7 +102,7 @@ class UserFragment : DialogFragment() {
|
|||
if (user != null) {
|
||||
dialog
|
||||
.getButton(AlertDialog.BUTTON_NEUTRAL)
|
||||
.dangerButton(requireContext())
|
||||
.dangerButton()
|
||||
}
|
||||
|
||||
// Validate input when typing
|
||||
|
|
|
|||
|
|
@ -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() {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
37
app/src/main/java/io/heckel/ntfy/util/ProgressRequestBody.kt
Normal file
37
app/src/main/java/io/heckel/ntfy/util/ProgressRequestBody.kt
Normal 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()
|
||||
}
|
||||
}
|
||||
|
|
@ -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? {
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
5
app/src/main/res/color/chip_background_state.xml
Normal file
5
app/src/main/res/color/chip_background_state.xml
Normal 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>
|
||||
9
app/src/main/res/drawable/ic_create_white_24dp.xml
Normal file
9
app/src/main/res/drawable/ic_create_white_24dp.xml
Normal 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>
|
||||
10
app/src/main/res/drawable/ic_expand_less_gray_24dp.xml
Normal file
10
app/src/main/res/drawable/ic_expand_less_gray_24dp.xml
Normal 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>
|
||||
|
||||
13
app/src/main/res/drawable/ic_priority_3_24dp.xml
Normal file
13
app/src/main/res/drawable/ic_priority_3_24dp.xml
Normal 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>
|
||||
|
||||
9
app/src/main/res/drawable/ic_send_gray_24dp.xml
Normal file
9
app/src/main/res/drawable/ic_send_gray_24dp.xml
Normal 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>
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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"/>
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
430
app/src/main/res/layout/fragment_publish_dialog.xml
Normal file
430
app/src/main/res/layout/fragment_publish_dialog.xml
Normal 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>
|
||||
28
app/src/main/res/layout/item_priority_dropdown.xml
Normal file
28
app/src/main/res/layout/item_priority_dropdown.xml
Normal 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>
|
||||
53
app/src/main/res/layout/view_attachment_box.xml
Normal file
53
app/src/main/res/layout/view_attachment_box.xml
Normal 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>
|
||||
83
app/src/main/res/layout/view_message_bar.xml
Normal file
83
app/src/main/res/layout/view_message_bar.xml
Normal 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>
|
||||
9
app/src/main/res/menu/menu_publish_dialog.xml
Normal file
9
app/src/main/res/menu/menu_publish_dialog.xml
Normal 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>
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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 l’autorisation.</string>
|
||||
<string name="settings_advanced_exact_alarms_false">ntfy ne peut pas programmer d’alarmes exactes. Les alarmes exactes sont nécessaires pour reconnecter les WebSockets en arrière-plan. Cliquez pour accorder l’autorisation.</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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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 & 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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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, WebSocket’lerin 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, WebSocket’lerin 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>
|
||||
|
|
|
|||
|
|
@ -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 & 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 & 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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
4
app/src/main/res/values/dimens.xml
Normal file
4
app/src/main/res/values/dimens.xml
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<dimen name="fab_margin">24dp</dimen>
|
||||
</resources>
|
||||
|
|
@ -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 & 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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
43
app/src/main/res/xml/locales_config.xml
Normal file
43
app/src/main/res/xml/locales_config.xml
Normal 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>
|
||||
|
|
@ -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
|
||||
|
|
|
|||
24
build.gradle
24
build.gradle
|
|
@ -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
|
||||
}
|
||||
5
fastlane/metadata/android/en-US/changelog/53.txt
Normal file
5
fastlane/metadata/android/en-US/changelog/53.txt
Normal 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)
|
||||
6
fastlane/metadata/android/en-US/changelog/NEXT.txt
Normal file
6
fastlane/metadata/android/en-US/changelog/NEXT.txt
Normal 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)
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -1 +1 @@
|
|||
ntfy - PUT/POST đến điện thoại của bạn
|
||||
ntfy - PUT/POST đến điện thoại
|
||||
|
|
|
|||
BIN
gradle/wrapper/gradle-wrapper.jar
vendored
BIN
gradle/wrapper/gradle-wrapper.jar
vendored
Binary file not shown.
5
gradle/wrapper/gradle-wrapper.properties
vendored
5
gradle/wrapper/gradle-wrapper.properties
vendored
|
|
@ -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
310
gradlew
vendored
|
|
@ -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
79
gradlew.bat
vendored
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue