Merge branch 'main' of github.com:binwiederhier/ntfy-android

This commit is contained in:
Philipp Heckel 2025-08-17 20:51:46 -04:00
commit 1900da7253
20 changed files with 250 additions and 47 deletions

View file

@ -1,18 +1,22 @@
plugins {
id 'com.google.devtools.ksp'
}
repositories {
mavenCentral()
}
apply plugin: 'com.android.application'
apply plugin: 'kotlin-android'
apply plugin: 'kotlin-kapt'
apply plugin: 'com.google.gms.google-services'
android {
compileSdkVersion 33
namespace "io.heckel.ntfy"
compileSdkVersion 34
defaultConfig {
applicationId "io.heckel.ntfy"
minSdkVersion 21
targetSdkVersion 33
targetSdkVersion 34
versionCode 33
versionName "1.17.0"
@ -25,6 +29,10 @@ android {
arguments += ["room.schemaLocation": "$projectDir/schemas".toString()]
}
}
buildFeatures {
buildConfig true
}
}
buildTypes {
@ -56,12 +64,12 @@ android {
}
compileOptions {
sourceCompatibility JavaVersion.VERSION_1_8
targetCompatibility JavaVersion.VERSION_1_8
sourceCompatibility JavaVersion.VERSION_17
targetCompatibility JavaVersion.VERSION_17
}
kotlinOptions {
jvmTarget = JavaVersion.VERSION_1_8.toString()
jvmTarget = JavaVersion.VERSION_17.toString()
freeCompilerArgs += [
'-Xjvm-default=all-compatibility' // https://stackoverflow.com/a/71234042/1440785
]
@ -105,9 +113,10 @@ dependencies {
implementation 'com.google.code.gson:gson:2.10'
// Room (SQLite)
def room_version = "2.5.1"
def room_version = "2.6.1"
implementation "androidx.room:room-runtime:$room_version"
ksp "androidx.room:room-compiler:$room_version"
implementation "androidx.room:room-ktx:$room_version"
kapt "androidx.room:room-compiler:$room_version"
// OkHttp (HTTP library)
implementation 'com.squareup.okhttp3:okhttp:4.10.0'
@ -129,7 +138,7 @@ dependencies {
implementation 'androidx.legacy:legacy-support-v4:1.0.0'
// Image viewer
implementation 'com.github.stfalcon-studio:StfalconImageViewer:v1.0.1'
implementation 'com.github.stfalcon-studio:StfalconImageViewer:1.0.1'
// Better click handling for links
implementation 'me.saket:better-link-movement-method:2.2.0'

View file

@ -1,10 +1,10 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
package="io.heckel.ntfy">
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<!-- Permissions -->
<uses-permission android:name="android.permission.INTERNET"/>
<uses-permission android:name="android.permission.FOREGROUND_SERVICE"/> <!-- For instant delivery foregrounds service -->
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_SPECIAL_USE"/> <!-- For instant delivery foregrounds service on SDK >= 34 -->
<uses-permission android:name="android.permission.WAKE_LOCK"/> <!-- To keep foreground service awake; soon not needed anymore -->
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED"/> <!-- To restart service on reboot -->
<uses-permission android:name="android.permission.VIBRATE"/> <!-- Incoming notifications should be able to vibrate the phone -->
@ -95,7 +95,13 @@
</activity>
<!-- Subscriber foreground service for hosts other than ntfy.sh -->
<service android:name=".service.SubscriberService"/>
<service
android:name=".service.SubscriberService"
android:foregroundServiceType="specialUse">
<property
android:name="android.app.PROPERTY_SPECIAL_USE_FGS_SUBTYPE"
android:value="This is the main feature of this application. The foreground notification displays the connectivity status to the configurable remote server and the service notifies the user when a new message has been published on the remote server." />
</service>
<!-- Subscriber service restart on reboot -->
<receiver
@ -104,6 +110,7 @@
android:exported="true">
<intent-filter>
<action android:name="android.intent.action.BOOT_COMPLETED"/>
<action android:name="android.app.action.SCHEDULE_EXACT_ALARM_PERMISSION_STATE_CHANGED"/>
</intent-filter>
</receiver>

View file

@ -125,7 +125,7 @@ data class Attachment(
@ColumnInfo(name = "contentUri") val contentUri: String?, // After it's downloaded, the content:// location
@ColumnInfo(name = "progress") val progress: Int, // Progress during download, -1 if not downloaded
) {
constructor(name: String, type: String?, size: Long?, expires: Long?, url: String) :
@Ignore constructor(name: String, type: String?, size: Long?, expires: Long?, url: String) :
this(name, type, size, expires, url, null, ATTACHMENT_PROGRESS_NONE)
}
@ -140,7 +140,7 @@ data class Icon(
@ColumnInfo(name = "url") val url: String, // URL (mandatory, see ntfy server)
@ColumnInfo(name = "contentUri") val contentUri: String?, // After it's downloaded, the content:// location
) {
constructor(url:String) :
@Ignore constructor(url:String) :
this(url, null)
}
@ -197,7 +197,7 @@ data class LogEntry(
@ColumnInfo(name = "message") val message: String,
@ColumnInfo(name = "exception") val exception: String?
) {
constructor(timestamp: Long, tag: String, level: Int, message: String, exception: String?) :
@Ignore constructor(timestamp: Long, tag: String, level: Int, message: String, exception: String?) :
this(0, timestamp, tag, level, message, exception)
}

View file

@ -342,6 +342,16 @@ class Repository(private val sharedPrefs: SharedPreferences, private val databas
.apply()
}
fun getWebSocketReconnectRemindTime(): Long {
return sharedPrefs.getLong(SHARED_PREFS_WEBSOCKET_RECONNECT_REMIND_TIME, WEBSOCKET_RECONNECT_REMIND_TIME_ALWAYS)
}
fun setWebSocketReconnectRemindTime(timeMillis: Long) {
sharedPrefs.edit()
.putLong(SHARED_PREFS_WEBSOCKET_RECONNECT_REMIND_TIME, timeMillis)
.apply()
}
fun getDefaultBaseUrl(): String? {
return sharedPrefs.getString(SHARED_PREFS_DEFAULT_BASE_URL, null) ?:
sharedPrefs.getString(SHARED_PREFS_UNIFIED_PUSH_BASE_URL, null) // Fall back to UP URL, removed when default is set!
@ -492,6 +502,7 @@ class Repository(private val sharedPrefs: SharedPreferences, private val databas
const val SHARED_PREFS_RECORD_LOGS_ENABLED = "RecordLogs"
const val SHARED_PREFS_BATTERY_OPTIMIZATIONS_REMIND_TIME = "BatteryOptimizationsRemindTime"
const val SHARED_PREFS_WEBSOCKET_REMIND_TIME = "JsonStreamRemindTime" // "Use WebSocket" banner (used to be JSON stream deprecation banner)
const val SHARED_PREFS_WEBSOCKET_RECONNECT_REMIND_TIME = "WebSocketReconnectRemindTime"
const val SHARED_PREFS_UNIFIED_PUSH_BASE_URL = "UnifiedPushBaseURL" // Legacy key required for migration to DefaultBaseURL
const val SHARED_PREFS_DEFAULT_BASE_URL = "DefaultBaseURL"
const val SHARED_PREFS_LAST_TOPICS = "LastTopics"
@ -532,6 +543,9 @@ class Repository(private val sharedPrefs: SharedPreferences, private val databas
const val WEBSOCKET_REMIND_TIME_ALWAYS = 1L
const val WEBSOCKET_REMIND_TIME_NEVER = Long.MAX_VALUE
const val WEBSOCKET_RECONNECT_REMIND_TIME_ALWAYS = 1L
const val WEBSOCKET_RECONNECT_REMIND_TIME_NEVER = Long.MAX_VALUE
private const val TAG = "NtfyRepository"
private var instance: Repository? = null

View file

@ -108,7 +108,6 @@ class ApiService {
fun subscribe(
baseUrl: String,
topics: String,
unifiedPushTopics: String,
since: String?,
user: User?,
notify: (topic: String, Notification) -> Unit,
@ -117,7 +116,7 @@ class ApiService {
val sinceVal = since ?: "all"
val url = topicUrlJson(baseUrl, topics, sinceVal)
Log.d(TAG, "Opening subscription connection to $url")
val request = requestBuilder(url, user, unifiedPushTopics).build()
val request = requestBuilder(url, user).build()
val call = subscriberClient.newCall(request)
call.enqueue(object : Callback {
override fun onResponse(call: Call, response: Response) {
@ -179,16 +178,13 @@ class ApiService {
const val EVENT_KEEPALIVE = "keepalive"
const val EVENT_POLL_REQUEST = "poll_request"
fun requestBuilder(url: String, user: User?, unifiedPushTopics: String? = null): Request.Builder {
fun requestBuilder(url: String, user: User?): Request.Builder {
val builder = Request.Builder()
.url(url)
.addHeader("User-Agent", USER_AGENT)
if (user != null) {
builder.addHeader("Authorization", Credentials.basic(user.username, user.password, UTF_8))
}
if (unifiedPushTopics != null) {
builder.addHeader("Rate-Topics", unifiedPushTopics)
}
return builder
}
}

View file

@ -8,6 +8,5 @@ interface Connection {
data class ConnectionId(
val baseUrl: String,
val topicsToSubscriptionIds: Map<String, Long>,
val topicIsUnifiedPush: Map<String, Boolean>
val topicsToSubscriptionIds: Map<String, Long>
)

View file

@ -21,10 +21,8 @@ class JsonConnection(
) : Connection {
private val baseUrl = connectionId.baseUrl
private val topicsToSubscriptionIds = connectionId.topicsToSubscriptionIds
private val topicIsUnifiedPush = connectionId.topicIsUnifiedPush
private val subscriptionIds = topicsToSubscriptionIds.values
private val topicsStr = topicsToSubscriptionIds.keys.joinToString(separator = ",")
private val unifiedPushTopicsStr = topicIsUnifiedPush.filter { entry -> entry.value }.keys.joinToString(separator = ",")
private val url = topicUrl(baseUrl, topicsStr)
private var since: String? = sinceId
@ -58,7 +56,7 @@ class JsonConnection(
// Call /json subscribe endpoint and loop until the call fails, is canceled,
// or the job or service are cancelled/stopped
try {
call = api.subscribe(baseUrl, topicsStr, unifiedPushTopicsStr, since, user, notify, fail)
call = api.subscribe(baseUrl, topicsStr, since, user, notify, fail)
while (!failed.get() && !call.isCanceled() && isActive && serviceActive()) {
stateChangeListener(subscriptionIds, ConnectionState.CONNECTED)
Log.d(TAG,"[$url] Connection is active (failed=$failed, callCanceled=${call.isCanceled()}, jobActive=$isActive, serviceStarted=${serviceActive()}")

View file

@ -4,6 +4,7 @@ import android.app.*
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import android.content.pm.ServiceInfo
import android.os.Build
import android.os.IBinder
import android.os.PowerManager
@ -98,7 +99,11 @@ class SubscriberService : Service() {
notificationManager = createNotificationChannel()
serviceNotification = createNotification(title, text)
startForeground(NOTIFICATION_SERVICE_ID, serviceNotification)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) {
startForeground(NOTIFICATION_SERVICE_ID, serviceNotification!!, ServiceInfo.FOREGROUND_SERVICE_TYPE_SPECIAL_USE)
} else {
startForeground(NOTIFICATION_SERVICE_ID, serviceNotification)
}
}
override fun onDestroy() {
@ -172,8 +177,8 @@ class SubscriberService : Service() {
.filter { s -> s.instant }
val activeConnectionIds = connections.keys().toList().toSet()
val desiredConnectionIds = instantSubscriptions // Set<ConnectionId>
.groupBy { s -> ConnectionId(s.baseUrl, emptyMap(), emptyMap()) }
.map { entry -> entry.key.copy(topicsToSubscriptionIds = entry.value.associate { s -> s.topic to s.id }, topicIsUnifiedPush = entry.value.associate { s -> s.topic to (s.upConnectorToken != null) }) }
.groupBy { s -> ConnectionId(s.baseUrl, emptyMap()) }
.map { entry -> entry.key.copy(topicsToSubscriptionIds = entry.value.associate { s -> s.topic to s.id }) }
.toSet()
val newConnectionIds = desiredConnectionIds.subtract(activeConnectionIds)
val obsoleteConnectionIds = activeConnectionIds.subtract(desiredConnectionIds)

View file

@ -56,10 +56,8 @@ class WsConnection(
private val since = AtomicReference<String?>(sinceId)
private val baseUrl = connectionId.baseUrl
private val topicsToSubscriptionIds = connectionId.topicsToSubscriptionIds
private val topicIsUnifiedPush = connectionId.topicIsUnifiedPush
private val subscriptionIds = topicsToSubscriptionIds.values
private val topicsStr = topicsToSubscriptionIds.keys.joinToString(separator = ",")
private val unifiedPushTopicsStr = topicIsUnifiedPush.filter { entry -> entry.value }.keys.joinToString(separator = ",")
private val shortUrl = topicShortUrl(baseUrl, topicsStr)
init {
@ -80,7 +78,7 @@ class WsConnection(
val sinceId = since.get()
val sinceVal = sinceId ?: "all"
val urlWithSince = topicUrlWs(baseUrl, topicsStr, sinceVal)
val request = requestBuilder(urlWithSince, user, unifiedPushTopicsStr).build()
val request = requestBuilder(urlWithSince, user).build()
Log.d(TAG, "$shortUrl (gid=$globalId): Opening $urlWithSince with listener ID $nextListenerId ...")
webSocket = client.newWebSocket(request, Listener(nextListenerId))
}
@ -114,7 +112,27 @@ class WsConnection(
Log.d(TAG,"$shortUrl (gid=$globalId): Scheduling a restart in $seconds seconds (via alarm manager)")
val reconnectTime = Calendar.getInstance()
reconnectTime.add(Calendar.SECOND, seconds)
alarmManager.setExact(AlarmManager.RTC_WAKEUP, reconnectTime.timeInMillis, RECONNECT_TAG, { start() }, null)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
if (alarmManager.canScheduleExactAlarms()) {
alarmManager.setExact(
AlarmManager.RTC_WAKEUP,
reconnectTime.timeInMillis,
RECONNECT_TAG,
{ start() },
null
)
} else {
Log.d(TAG, "SCHEDULE_EXACT_ALARM permission denied: Failed to reschedule websocket connection")
}
} else {
alarmManager.setExact(
AlarmManager.RTC_WAKEUP,
reconnectTime.timeInMillis,
RECONNECT_TAG,
{ start() },
null
)
}
} else {
Log.d(TAG, "$shortUrl (gid=$globalId): Scheduling a restart in $seconds seconds (via handler)")
val handler = Handler(Looper.getMainLooper())

View file

@ -7,7 +7,7 @@ import io.heckel.ntfy.util.isDarkThemeOn
class Colors {
companion object {
const val refreshProgressIndicator = R.color.teal
val refreshProgressIndicator = R.color.teal
fun notificationIcon(context: Context): Int {
return if (isDarkThemeOn(context)) R.color.teal_light else R.color.teal

View file

@ -3,6 +3,7 @@ package io.heckel.ntfy.ui
import android.Manifest
import android.animation.Animator
import android.animation.AnimatorListenerAdapter
import android.app.AlarmManager
import android.app.AlertDialog
import android.content.ActivityNotFoundException
import android.content.Intent
@ -11,6 +12,7 @@ import android.net.Uri
import android.os.Build
import android.os.Bundle
import android.provider.Settings
import android.provider.Settings.ACTION_REQUEST_SCHEDULE_EXACT_ALARM
import android.text.method.LinkMovementMethod
import android.view.ActionMode
import android.view.Menu
@ -125,9 +127,10 @@ class MainActivity : AppCompatActivity(), ActionMode.Callback, AddFragment.Subsc
Log.addScrubTerm(s.topic)
}
// Update banner + WebSocket banner
// Update battery banner + WebSocket banner + websocket reconnect banner
showHideBatteryBanner(subscriptions)
showHideWebSocketBanner(subscriptions)
showHideWebSocketReconnectBanner(subscriptions)
}
}
@ -194,6 +197,34 @@ class MainActivity : AppCompatActivity(), ActionMode.Callback, AddFragment.Subsc
repository.setConnectionProtocol(Repository.CONNECTION_PROTOCOL_WS)
SubscriberServiceManager(this).restart()
wsBanner.visibility = View.GONE
// Maybe show WebSocketReconnectBanner
viewModel.list().observe(this) {
it?.let { subscriptions ->
showHideWebSocketReconnectBanner(subscriptions)
}
}
}
// WebSocket Reconnect banner
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
val wsReconnectBanner = findViewById<View>(R.id.main_banner_websocket_reconnect)
val wsReconnectText = findViewById<TextView>(R.id.main_banner_websocket_reconnect_text)
val wsReconnectDismissButton = findViewById<Button>(R.id.main_banner_websocket_reconnect_dontaskagain)
val wsReconnectRemindButton = findViewById<Button>(R.id.main_banner_websocket_reconnect_remind_later)
val wsReconnectEnableButton = findViewById<Button>(R.id.main_banner_websocket_reconnect_enable)
wsReconnectText.movementMethod = LinkMovementMethod.getInstance() // Make links clickable
wsReconnectDismissButton.setOnClickListener {
wsReconnectBanner.visibility = View.GONE
repository.setWebSocketReconnectRemindTime(Repository.WEBSOCKET_RECONNECT_REMIND_TIME_NEVER)
}
wsReconnectRemindButton.setOnClickListener {
wsReconnectBanner.visibility = View.GONE
repository.setWebSocketReconnectRemindTime(System.currentTimeMillis() + ONE_DAY_MILLIS)
}
wsReconnectEnableButton.setOnClickListener {
startActivity(Intent(ACTION_REQUEST_SCHEDULE_EXACT_ALARM))
}
}
// Create notification channels right away, so we can configure them immediately after installing the app
@ -248,6 +279,21 @@ class MainActivity : AppCompatActivity(), ActionMode.Callback, AddFragment.Subsc
wsBanner.visibility = if (showBanner) View.VISIBLE else View.GONE
}
private fun showHideWebSocketReconnectBanner(subscriptions: List<Subscription>) {
val wsReconnectBanner = findViewById<View>(R.id.main_banner_websocket_reconnect)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
val hasSelfHostedSubscriptions = subscriptions.count { it.baseUrl != appBaseUrl } > 0
val usingWebSockets = repository.getConnectionProtocol() == Repository.CONNECTION_PROTOCOL_WS
val wsReconnectRemindTimeReached = repository.getWebSocketReconnectRemindTime() < System.currentTimeMillis()
val canScheduleExactAlarms = (getSystemService(ALARM_SERVICE) as AlarmManager).canScheduleExactAlarms()
val showBanner = hasSelfHostedSubscriptions && wsReconnectRemindTimeReached && usingWebSockets && !canScheduleExactAlarms
Log.d(TAG, "hasSelfHostedSubscriptions: ${hasSelfHostedSubscriptions}, wsReconnectRemindTimeReached: ${wsReconnectRemindTimeReached}, usingWebSockets: ${usingWebSockets}, canScheduleExactAlarms: ${canScheduleExactAlarms}")
wsReconnectBanner.visibility = if (showBanner) View.VISIBLE else View.GONE
} else {
wsReconnectBanner.visibility = View.GONE
}
}
private fun schedulePeriodicPollWorker() {
val workerVersion = repository.getPollWorkerVersion()
val workPolicy = if (workerVersion == PollWorker.VERSION) {

View file

@ -1,6 +1,7 @@
package io.heckel.ntfy.ui
import android.Manifest
import android.app.AlarmManager
import android.app.AlertDialog
import android.content.ClipData
import android.content.ClipboardManager
@ -10,6 +11,7 @@ import android.content.pm.PackageManager
import android.os.Build
import android.os.Bundle
import android.provider.Settings
import android.provider.Settings.ACTION_REQUEST_SCHEDULE_EXACT_ALARM
import android.text.TextUtils
import android.widget.Button
import android.widget.Toast
@ -119,6 +121,11 @@ class SettingsActivity : AppCompatActivity(), PreferenceFragmentCompat.OnPrefere
return true
}
override fun onResume() {
super.onResume()
settingsFragment.updateExactAlarmsPref()
}
class SettingsFragment : PreferenceFragmentCompat() {
private lateinit var repository: Repository
private lateinit var serviceManager: SubscriberServiceManager
@ -545,6 +552,9 @@ class SettingsActivity : AppCompatActivity(), PreferenceFragmentCompat.OnPrefere
}
}
// CanScheduleExactAlarms
updateExactAlarmsPref()
// Version
val versionPrefId = context?.getString(R.string.settings_about_version_key) ?: return
val versionPref: Preference? = findPreference(versionPrefId)
@ -678,6 +688,23 @@ class SettingsActivity : AppCompatActivity(), PreferenceFragmentCompat.OnPrefere
}
}
fun updateExactAlarmsPref() {
val exactAlarmsPrefId = context?.getString(R.string.settings_advanced_exact_alarms_key) ?: return
val exactAlarmsPref: Preference? = findPreference(exactAlarmsPrefId)
val canScheduleExactAlarms = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
(activity?.getSystemService(ALARM_SERVICE) as AlarmManager).canScheduleExactAlarms()
} else {
true
}
exactAlarmsPref?.summary = if (canScheduleExactAlarms) getString(R.string.settings_advanced_exact_alarms_true) else getString(R.string.settings_advanced_exact_alarms_false)
exactAlarmsPref?.onPreferenceClickListener = OnPreferenceClickListener {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU && !canScheduleExactAlarms) {
startActivity(Intent(ACTION_REQUEST_SCHEDULE_EXACT_ALARM))
}
true
}
}
@Keep
data class NopasteResponse(val url: String)
}

View file

@ -393,13 +393,14 @@ class ContentUriRequestBody(
}
}
// TODO: make this work in Android 34+
// Hack: Make end icon for drop down smaller, see https://stackoverflow.com/a/57098715/1440785
fun View.makeEndIconSmaller(resources: Resources) {
val dimension = TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, 30f, resources.displayMetrics)
val endIconImageView = findViewById<ImageView>(R.id.text_input_end_icon)
endIconImageView.minimumHeight = dimension.toInt()
endIconImageView.minimumWidth = dimension.toInt()
requestLayout()
// val dimension = TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, 30f, resources.displayMetrics)
// val endIconImageView = findViewById<ImageView>(R.id.text_input_end_icon)
// endIconImageView.minimumHeight = dimension.toInt()
// endIconImageView.minimumWidth = dimension.toInt()
// requestLayout()
}
// Shows the ripple effect on the view, if it is ripple-able, see https://stackoverflow.com/a/56314062/1440785

View file

@ -148,6 +148,73 @@
</androidx.constraintlayout.widget.ConstraintLayout>
</com.google.android.material.card.MaterialCardView>
<com.google.android.material.card.MaterialCardView
android:layout_width="match_parent"
android:layout_height="wrap_content"
app:shapeAppearance="?shapeAppearanceLargeComponent" app:layout_constraintStart_toStartOf="parent"
app:layout_constraintEnd_toEndOf="parent" app:layout_constraintTop_toBottomOf="@+id/main_banner_websocket"
android:id="@+id/main_banner_websocket_reconnect" android:visibility="visible">
<androidx.constraintlayout.widget.ConstraintLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:id="@+id/main_banner_websocket_reconnect_constraint" android:elevation="5dp">
<ImageView
android:layout_width="28dp"
android:layout_height="28dp" app:srcCompat="@drawable/ic_announcement_orange_24dp"
android:id="@+id/main_banner_websocket_reconnect_image"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="@+id/main_banner_websocket_reconnect_text"
app:layout_constraintEnd_toStartOf="@id/main_banner_websocket_reconnect_text"
app:layout_constraintBottom_toBottomOf="@+id/main_banner_websocket_reconnect_text"
android:layout_marginStart="15dp"/>
<TextView
android:id="@+id/main_banner_websocket_reconnect_text"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:text="@string/main_banner_websocket_reconnect_text"
android:textAppearance="@style/TextAppearance.AppCompat.Small"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintTop_toTopOf="parent"
android:layout_marginEnd="15dp" android:layout_marginTop="15dp"
app:layout_constraintStart_toEndOf="@+id/main_banner_websocket_reconnect_image"
android:layout_marginStart="10dp"
/>
<androidx.constraintlayout.helper.widget.Flow
android:layout_width="0dp"
android:layout_height="wrap_content"
app:layout_constraintEnd_toEndOf="parent"
app:constraint_referenced_ids="main_banner_websocket_reconnect_remind_later,main_banner_websocket_reconnect_dontaskagain,main_banner_websocket_reconnect_enable" app:layout_constraintTop_toBottomOf="@id/main_banner_websocket_reconnect_text" app:flow_horizontalAlign="end" app:flow_wrapMode="chain" app:flow_horizontalStyle="packed" android:layout_marginEnd="15dp" android:id="@+id/flow_reconnect" app:layout_constraintStart_toStartOf="parent" android:layout_marginStart="15dp" app:flow_horizontalBias="1"
app:flow_verticalGap="0dp" app:flow_horizontalGap="0dp"/>
<com.google.android.material.button.MaterialButton
android:id="@+id/main_banner_websocket_reconnect_remind_later"
style="@style/Widget.MaterialComponents.Button.TextButton"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/main_banner_websocket_reconnect_button_remind_later"
tools:layout_editor_absoluteX="86dp" tools:layout_editor_absoluteY="83dp"/>
<com.google.android.material.button.MaterialButton
android:id="@+id/main_banner_websocket_reconnect_dontaskagain"
style="@style/Widget.MaterialComponents.Button.TextButton"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/main_banner_websocket_reconnect_button_dismiss"
tools:layout_editor_absoluteX="260dp" tools:layout_editor_absoluteY="83dp"/>
<com.google.android.material.button.MaterialButton
android:id="@+id/main_banner_websocket_reconnect_enable"
style="@style/Widget.MaterialComponents.Button.TextButton"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/main_banner_websocket_reconnect_button_enable_now"
tools:layout_editor_absoluteX="253dp" tools:layout_editor_absoluteY="131dp"/>
</androidx.constraintlayout.widget.ConstraintLayout>
</com.google.android.material.card.MaterialCardView>
<androidx.swiperefreshlayout.widget.SwipeRefreshLayout
android:id="@+id/main_subscriptions_list_container"
android:layout_width="match_parent"
@ -155,7 +222,7 @@
android:visibility="visible"
app:layout_constraintStart_toStartOf="parent" app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintTop_toBottomOf="@id/main_banner_websocket">
app:layout_constraintTop_toBottomOf="@id/main_banner_websocket_reconnect">
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/main_subscriptions_list"
android:layout_width="match_parent"

View file

@ -82,6 +82,12 @@
<string name="main_banner_websocket_button_dismiss">Dismiss</string>
<string name="main_banner_websocket_button_enable_now">Enable now</string>
<!-- Main activity: WebSocket Reconnect banner -->
<string name="main_banner_websocket_reconnect_text">To ensure WebSockets reconnect in the background, grant the "Alarm &amp; Reminders" permission to ntfy</string>
<string name="main_banner_websocket_reconnect_button_remind_later">Ask later</string>
<string name="main_banner_websocket_reconnect_button_dismiss">Dismiss</string>
<string name="main_banner_websocket_reconnect_button_enable_now">Grant now</string>
<!-- Add dialog -->
<string name="add_dialog_title">Subscribe to topic</string>
<string name="add_dialog_description_below">
@ -347,6 +353,9 @@
<string name="settings_advanced_connection_protocol_summary_ws">Use WebSockets to connect to the server. This is the recommended method, but may require additional config in your proxy.</string>
<string name="settings_advanced_connection_protocol_entry_jsonhttp">JSON stream over HTTP</string>
<string name="settings_advanced_connection_protocol_entry_ws">WebSockets</string>
<string name="settings_advanced_exact_alarms_title">Exact Alarms</string>
<string name="settings_advanced_exact_alarms_true">ntfy can schedule exact alarms (used to reconnect WebSockets in the background)</string>
<string name="settings_advanced_exact_alarms_false">ntfy CANNOT schedule exact alarms. Click to grant the permission now</string>
<string name="settings_about_header">About</string>
<string name="settings_about_version_title">Version</string>
<string name="settings_about_version_format">ntfy %1$s (%2$s)</string>

View file

@ -30,6 +30,7 @@
<string name="settings_advanced_export_logs_key" translatable="false">ExportLogs</string>
<string name="settings_advanced_clear_logs_key" translatable="false">ClearLogs</string>
<string name="settings_advanced_connection_protocol_key" translatable="false">ConnectionProtocol</string>
<string name="settings_advanced_exact_alarms_key" translatable="false">ExactAlarms</string>
<string name="settings_about_version_key" translatable="false">Version</string>
<!-- Detail settings constants -->

View file

@ -72,6 +72,9 @@
app:entries="@array/settings_advanced_connection_protocol_entries"
app:entryValues="@array/settings_advanced_connection_protocol_values"
app:defaultValue="jsonhttp"/>
<Preference
app:key="@string/settings_advanced_exact_alarms_key"
app:title="@string/settings_advanced_exact_alarms_title"/>
<SwitchPreference
app:key="@string/settings_advanced_broadcast_key"
app:title="@string/settings_advanced_broadcast_title"

View file

@ -1,11 +1,11 @@
buildscript {
ext.kotlin_version = '1.8.20'
ext.kotlin_version = '2.0.21'
repositories {
google()
mavenCentral()
}
dependencies {
classpath 'com.android.tools.build:gradle:7.4.2'
classpath 'com.android.tools.build:gradle:8.7.3'
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
@ -14,6 +14,10 @@ buildscript {
}
}
plugins {
id 'com.google.devtools.ksp' version '2.0.21-1.0.27'
}
allprojects {
repositories {
google()
@ -24,4 +28,4 @@ allprojects {
task clean(type: Delete) {
delete rootProject.buildDir
}
}

View file

@ -2,7 +2,6 @@ Features:
* You can now disable UnifiedPush so ntfy does not act as a UnifiedPush distributor (#646, thanks to @ollien for reporting, and to @wunter8 for implementing)
Bug fixes + maintenance:
* UnifiedPush subscriptions now include the Rate-Topics header to facilitate subscriber-based billing (#652, thanks to @wunter8)
* Subscriptions without icons no longer appear to use another subscription's icon (#634, thanks to @topcaser for reporting, and @wunter8 for fixing)
* Bumped all dependencies to the latest versions (no ticket)

View file

@ -1,6 +1,6 @@
#Tue Sep 22 10:13:03 PDT 2020
#Sat Feb 22 14:52:01 MST 2025
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-8.10.2-bin.zip
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-7.5-bin.zip