Merge branch 'main' of github.com:binwiederhier/ntfy-android
This commit is contained in:
commit
1900da7253
20 changed files with 250 additions and 47 deletions
|
|
@ -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'
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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()}")
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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())
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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 & 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>
|
||||
|
|
|
|||
|
|
@ -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 -->
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
10
build.gradle
10
build.gradle
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
4
gradle/wrapper/gradle-wrapper.properties
vendored
4
gradle/wrapper/gradle-wrapper.properties
vendored
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue