Compare commits

..

19 commits

Author SHA1 Message Date
Jonathan Klee
3fb7e6427f Merge branch '2585-u-change-package-name' into 'main'
Change Ntfy package name

See merge request e/os/ntfy-android!14
2024-10-24 11:26:08 +00:00
Jonathan Klee
afeebcc4d1 Change package name
So that in case the user had ntfy already installed, we don't
override it. Indeed, the Ntfy icon (which is actually a BlissLauncher3 cache)
would appear after the OTA and the user would not be able to remove it
without cleaning BlissLauncher3 cache.
2024-10-24 10:14:11 +02:00
Jonathan Klee
9add68ed1a Change preference wording 2024-10-10 14:42:34 +02:00
Jonathan Klee
18fb197507 Merge branch '0000-u-keep-same-process' into 'main'
Don't execute MainSettingsActivity in a different process

See merge request e/os/ntfy-android!13
2024-09-26 08:55:37 +00:00
Jonathan Klee
7c00376ae1 Don't execute MainSettingsActivity in a different process
so that share preferences work properly
2024-09-26 08:20:17 +02:00
Jonathan Klee
db299383c8 Use fdroid target 2024-09-25 09:04:23 +02:00
Jonathan Klee
e480230f27 Add gitlab yaml 2024-09-25 08:48:44 +02:00
Jonathan Klee
92611d1e81 Merge branch 'simpleran' into 'main'
Integrate ntfy in /e/OS

See merge request e/os/ntfy-android!12
2024-09-20 16:05:59 +00:00
Jonathan Klee
3b1581a2ba Set status bar theme programatically 2024-09-20 14:57:10 +02:00
Jonathan Klee
78bf83acf4 Extract isEnabled key as resource 2024-09-20 13:42:47 +02:00
Jonathan Klee
5684aba36d Implement NavigationBar theme 2024-09-20 13:31:20 +02:00
Jonathan Klee
61dadcba85 Add translations 2024-09-20 13:31:20 +02:00
Jonathan Klee
35711e7c1b Implement collapsing toolbar 2024-09-20 13:31:20 +02:00
Jonathan Klee
4f97fee55c Adjust styles 2024-09-20 13:31:15 +02:00
Jonathan Klee
fe86329af5 Add Preferences to enable Ntfy in the Settings app 2024-09-19 14:25:02 +02:00
Jonathan Klee
160c4cfd88 Don't show app on the launcher 2024-09-04 17:01:20 +02:00
Jonathan Klee
a4eb268aa0 Exclude app from Android recents app view 2024-09-04 17:00:31 +02:00
Jonathan Klee
95ab609d94 Make the app persistent so that it does not get killed 2024-09-04 16:59:53 +02:00
Jonathan Klee
5aceba6474 Start SubscriberService as background service 2024-09-04 16:59:36 +02:00
58 changed files with 650 additions and 506 deletions

21
.gitlab-ci.yml Normal file
View file

@ -0,0 +1,21 @@
image: "registry.gitlab.e.foundation/e/os/docker-android-apps-cicd:latest"
stages:
- build
before_script:
- export GRADLE_USER_HOME=$(pwd)/.gradle
- chmod +x ./gradlew
cache:
key: ${CI_PROJECT_ID}
paths:
- .gradle/
buildRelease:
stage: build
script:
- ./gradlew assembleFdroid
artifacts:
paths:
- app/build/outputs/apk/fdroid/release

View file

@ -10,8 +10,8 @@ android {
compileSdkVersion 33 compileSdkVersion 33
defaultConfig { defaultConfig {
applicationId "io.heckel.ntfy" applicationId "foundation.e.ntfy"
minSdkVersion 21 minSdkVersion 23
targetSdkVersion 33 targetSdkVersion 33
versionCode 33 versionCode 33
@ -27,6 +27,10 @@ android {
} }
} }
buildFeatures {
viewBinding = true
}
buildTypes { buildTypes {
release { release {
minifyEnabled true minifyEnabled true
@ -41,7 +45,14 @@ android {
flavorDimensions "store" flavorDimensions "store"
productFlavors { productFlavors {
play {
buildConfigField 'boolean', 'FIREBASE_AVAILABLE', 'true'
buildConfigField 'boolean', 'RATE_APP_AVAILABLE', 'true'
buildConfigField 'boolean', 'INSTALL_PACKAGES_AVAILABLE', 'false'
}
fdroid { fdroid {
buildConfigField 'boolean', 'FIREBASE_AVAILABLE', 'false'
buildConfigField 'boolean', 'RATE_APP_AVAILABLE', 'false'
buildConfigField 'boolean', 'INSTALL_PACKAGES_AVAILABLE', 'true' buildConfigField 'boolean', 'INSTALL_PACKAGES_AVAILABLE', 'true'
} }
} }
@ -57,6 +68,7 @@ android {
'-Xjvm-default=all-compatibility' // https://stackoverflow.com/a/71234042/1440785 '-Xjvm-default=all-compatibility' // https://stackoverflow.com/a/71234042/1440785
] ]
} }
namespace "io.heckel.ntfy"
} }
// Disables GoogleServices tasks for F-Droid variant // Disables GoogleServices tasks for F-Droid variant
@ -103,6 +115,9 @@ dependencies {
// OkHttp (HTTP library) // OkHttp (HTTP library)
implementation 'com.squareup.okhttp3:okhttp:4.10.0' implementation 'com.squareup.okhttp3:okhttp:4.10.0'
// Firebase, sigh ... (only Google Play)
playImplementation 'com.google.firebase:firebase-messaging:23.1.2'
// RecyclerView // RecyclerView
implementation "androidx.recyclerview:recyclerview:1.3.0" implementation "androidx.recyclerview:recyclerview:1.3.0"
@ -118,4 +133,6 @@ dependencies {
// Image viewer // Image viewer
implementation 'com.github.stfalcon-studio:StfalconImageViewer:v1.0.1' implementation 'com.github.stfalcon-studio:StfalconImageViewer:v1.0.1'
implementation 'foundation.e:elib:0.0.1-alpha11'
} }

View file

@ -0,0 +1,12 @@
package io.heckel.ntfy.firebase
@Suppress("UNUSED_PARAMETER")
class FirebaseMessenger {
fun subscribe(topic: String) {
// Dummy to keep F-Droid flavor happy
}
fun unsubscribe(topic: String) {
// Dummy to keep F-Droid flavor happy
}
}

View file

@ -0,0 +1,12 @@
package io.heckel.ntfy.firebase
import android.app.Service
import android.content.Intent
import android.os.IBinder
// Dummy to keep F-Droid flavor happy
class FirebaseService : Service() {
override fun onBind(intent: Intent?): IBinder? {
return null
}
}

View file

@ -1,6 +1,6 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android" <manifest xmlns:android="http://schemas.android.com/apk/res/android"
package="io.heckel.ntfy"> xmlns:tools="http://schemas.android.com/tools">
<!-- Permissions --> <!-- Permissions -->
<uses-permission android:name="android.permission.INTERNET"/> <uses-permission android:name="android.permission.INTERNET"/>
@ -25,6 +25,7 @@
android:allowBackup="true" android:allowBackup="true"
android:icon="@mipmap/ic_launcher" android:icon="@mipmap/ic_launcher"
android:label="@string/app_name" android:label="@string/app_name"
android:persistent="true"
android:roundIcon="@mipmap/ic_launcher" android:roundIcon="@mipmap/ic_launcher"
android:supportsRtl="true" android:supportsRtl="true"
android:theme="@style/AppTheme" android:theme="@style/AppTheme"
@ -35,10 +36,10 @@
<activity <activity
android:name=".ui.MainActivity" android:name=".ui.MainActivity"
android:label="@string/app_name" android:label="@string/app_name"
android:excludeFromRecents="true"
android:exported="true"> android:exported="true">
<intent-filter> <intent-filter>
<action android:name="android.intent.action.MAIN"/> <action android:name="android.intent.action.MAIN"/>
<category android:name="android.intent.category.LAUNCHER"/>
</intent-filter> </intent-filter>
</activity> </activity>
@ -175,5 +176,25 @@
android:name="android.support.FILE_PROVIDER_PATHS" android:name="android.support.FILE_PROVIDER_PATHS"
android:resource="@xml/file_paths"/> android:resource="@xml/file_paths"/>
</provider> </provider>
<activity
android:name=".ui.MainSettingsActivity"
android:theme="@style/PreferenceTheme"/>
<activity-alias
android:name=".ui.SettingsActivityLink"
android:exported="true"
android:label="@string/eos_settings_title"
android:targetActivity=".ui.MainSettingsActivity">
<intent-filter>
<action android:name="com.android.settings.action.EXTRA_SETTINGS" />
</intent-filter>
<meta-data
android:name="com.android.settings.category"
android:value="com.android.settings.category.device" />
<meta-data
android:name="com.android.settings.icon"
android:resource="@drawable/ic_notification" />
</activity-alias>
</application> </application>
</manifest> </manifest>

View file

@ -8,6 +8,7 @@ import com.google.gson.stream.JsonReader
import io.heckel.ntfy.R import io.heckel.ntfy.R
import io.heckel.ntfy.app.Application import io.heckel.ntfy.app.Application
import io.heckel.ntfy.db.Repository import io.heckel.ntfy.db.Repository
import io.heckel.ntfy.firebase.FirebaseMessenger
import io.heckel.ntfy.msg.NotificationService import io.heckel.ntfy.msg.NotificationService
import io.heckel.ntfy.util.Log import io.heckel.ntfy.util.Log
import io.heckel.ntfy.util.topicUrl import io.heckel.ntfy.util.topicUrl
@ -17,6 +18,7 @@ class Backuper(val context: Context) {
private val gson = Gson() private val gson = Gson()
private val resolver = context.applicationContext.contentResolver private val resolver = context.applicationContext.contentResolver
private val repository = (context.applicationContext as Application).repository private val repository = (context.applicationContext as Application).repository
private val messenger = FirebaseMessenger()
private val notifier = NotificationService(context) private val notifier = NotificationService(context)
suspend fun backup(uri: Uri, withSettings: Boolean = true, withSubscriptions: Boolean = true, withUsers: Boolean = true) { suspend fun backup(uri: Uri, withSettings: Boolean = true, withSubscriptions: Boolean = true, withUsers: Boolean = true) {
@ -112,6 +114,11 @@ class Backuper(val context: Context) {
) )
repository.addSubscription(subscription) repository.addSubscription(subscription)
// Subscribe to Firebase topics
if (s.baseUrl == appBaseUrl) {
messenger.subscribe(s.topic)
}
// Create dedicated channels // Create dedicated channels
if (s.dedicatedChannels) { if (s.dedicatedChannels) {
notifier.createSubscriptionNotificationChannels(subscription) notifier.createSubscriptionNotificationChannels(subscription)

View file

@ -10,6 +10,7 @@ import android.os.PowerManager
import android.os.SystemClock import android.os.SystemClock
import androidx.core.app.NotificationCompat import androidx.core.app.NotificationCompat
import androidx.core.content.ContextCompat import androidx.core.content.ContextCompat
import androidx.preference.PreferenceManager
import io.heckel.ntfy.BuildConfig import io.heckel.ntfy.BuildConfig
import io.heckel.ntfy.R import io.heckel.ntfy.R
import io.heckel.ntfy.app.Application import io.heckel.ntfy.app.Application
@ -88,19 +89,16 @@ class SubscriberService : Service() {
Log.init(this) // Init logs in all entry points Log.init(this) // Init logs in all entry points
Log.d(TAG, "Subscriber service has been created") Log.d(TAG, "Subscriber service has been created")
val title = getString(R.string.channel_subscriber_notification_title)
val text = getString(R.string.channel_subscriber_notification_noinstant_text)
notificationManager = createNotificationChannel()
serviceNotification = createNotification(title, text)
startForeground(NOTIFICATION_SERVICE_ID, serviceNotification)
} }
override fun onDestroy() { override fun onDestroy() {
Log.d(TAG, "Subscriber service has been destroyed") Log.d(TAG, "Subscriber service has been destroyed")
stopService() stopService()
sendBroadcast(Intent(this, AutoRestartReceiver::class.java)) // Restart it if necessary! val preferenceKey = getString(R.string.eos_preference_key_is_enabled)
if (PreferenceManager.getDefaultSharedPreferences(this).getBoolean(preferenceKey, false)) {
sendBroadcast(Intent(this, AutoRestartReceiver::class.java))
}
super.onDestroy() super.onDestroy()
} }
@ -134,7 +132,6 @@ class SubscriberService : Service() {
} }
} }
wakeLock = null wakeLock = null
stopForeground(true)
stopSelf() stopSelf()
} catch (e: Exception) { } catch (e: Exception) {
Log.d(TAG, "Service stopped without being started: ${e.message}") Log.d(TAG, "Service stopped without being started: ${e.message}")
@ -217,7 +214,18 @@ class SubscriberService : Service() {
// Update foreground service notification popup // Update foreground service notification popup
if (connections.size > 0) { if (connections.size > 0) {
val title = getString(R.string.channel_subscriber_notification_title) val title = getString(R.string.channel_subscriber_notification_title)
val text = when (instantSubscriptions.size) { val text = if (BuildConfig.FIREBASE_AVAILABLE) {
when (instantSubscriptions.size) {
1 -> getString(R.string.channel_subscriber_notification_instant_text_one)
2 -> getString(R.string.channel_subscriber_notification_instant_text_two)
3 -> getString(R.string.channel_subscriber_notification_instant_text_three)
4 -> getString(R.string.channel_subscriber_notification_instant_text_four)
5 -> getString(R.string.channel_subscriber_notification_instant_text_five)
6 -> getString(R.string.channel_subscriber_notification_instant_text_six)
else -> getString(R.string.channel_subscriber_notification_instant_text_more, instantSubscriptions.size)
}
} else {
when (instantSubscriptions.size) {
1 -> getString(R.string.channel_subscriber_notification_noinstant_text_one) 1 -> getString(R.string.channel_subscriber_notification_noinstant_text_one)
2 -> getString(R.string.channel_subscriber_notification_noinstant_text_two) 2 -> getString(R.string.channel_subscriber_notification_noinstant_text_two)
3 -> getString(R.string.channel_subscriber_notification_noinstant_text_three) 3 -> getString(R.string.channel_subscriber_notification_noinstant_text_three)
@ -226,6 +234,7 @@ class SubscriberService : Service() {
6 -> getString(R.string.channel_subscriber_notification_noinstant_text_six) 6 -> getString(R.string.channel_subscriber_notification_noinstant_text_six)
else -> getString(R.string.channel_subscriber_notification_noinstant_text_more, instantSubscriptions.size) else -> getString(R.string.channel_subscriber_notification_noinstant_text_more, instantSubscriptions.size)
} }
}
serviceNotification = createNotification(title, text) serviceNotification = createNotification(title, text)
notificationManager?.notify(NOTIFICATION_SERVICE_ID, serviceNotification) notificationManager?.notify(NOTIFICATION_SERVICE_ID, serviceNotification)
} }

View file

@ -2,10 +2,11 @@ package io.heckel.ntfy.service
import android.content.Context import android.content.Context
import android.content.Intent import android.content.Intent
import androidx.core.content.ContextCompat import androidx.preference.PreferenceManager
import androidx.work.* import androidx.work.*
import io.heckel.ntfy.app.Application import io.heckel.ntfy.app.Application
import io.heckel.ntfy.util.Log import io.heckel.ntfy.util.Log
import io.heckel.ntfy.R
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
@ -43,11 +44,17 @@ class SubscriberServiceManager(private val context: Context) {
Log.d(TAG, "ServiceStartWorker: Failed, no application found (work ID: ${id})") Log.d(TAG, "ServiceStartWorker: Failed, no application found (work ID: ${id})")
return Result.failure() return Result.failure()
} }
withContext(Dispatchers.IO) { withContext(Dispatchers.IO) {
val app = context.applicationContext as Application val app = context.applicationContext as Application
val subscriptionIdsWithInstantStatus = app.repository.getSubscriptionIdsWithInstantStatus() val sharedPreferences = PreferenceManager.getDefaultSharedPreferences(app)
val instantSubscriptions = subscriptionIdsWithInstantStatus.toList().filter { (_, instant) -> instant }.size val preferenceKey = context.getString(R.string.eos_preference_key_is_enabled)
val action = if (instantSubscriptions > 0) SubscriberService.Action.START else SubscriberService.Action.STOP val action = if (sharedPreferences.getBoolean(preferenceKey, false)) {
SubscriberService.Action.START
} else {
SubscriberService.Action.STOP
}
val serviceState = SubscriberService.readServiceState(context) val serviceState = SubscriberService.readServiceState(context)
if (serviceState == SubscriberService.ServiceState.STOPPED && action == SubscriberService.Action.STOP) { if (serviceState == SubscriberService.ServiceState.STOPPED && action == SubscriberService.Action.STOP) {
return@withContext Result.success() return@withContext Result.success()
@ -55,7 +62,7 @@ class SubscriberServiceManager(private val context: Context) {
Log.d(TAG, "ServiceStartWorker: Starting foreground service with action $action (work ID: ${id})") Log.d(TAG, "ServiceStartWorker: Starting foreground service with action $action (work ID: ${id})")
Intent(context, SubscriberService::class.java).also { Intent(context, SubscriberService::class.java).also {
it.action = action.name it.action = action.name
ContextCompat.startForegroundService(context, it) context.startService(it)
} }
} }
return Result.success() return Result.success()

View file

@ -114,7 +114,10 @@ class AddFragment : DialogFragment() {
// Set foreground description text // Set foreground description text
subscribeForegroundDescription.text = getString(R.string.add_dialog_foreground_description, shortUrl(appBaseUrl)) subscribeForegroundDescription.text = getString(R.string.add_dialog_foreground_description, shortUrl(appBaseUrl))
// Show/hide based on flavor (faster shortcut for validateInputSubscribeView, which can only run onShow)
if (!BuildConfig.FIREBASE_AVAILABLE) {
subscribeInstantDeliveryBox.visibility = View.GONE subscribeInstantDeliveryBox.visibility = View.GONE
}
// Add baseUrl auto-complete behavior // Add baseUrl auto-complete behavior
lifecycleScope.launch(Dispatchers.IO) { lifecycleScope.launch(Dispatchers.IO) {
@ -288,6 +291,16 @@ class AddFragment : DialogFragment() {
private fun validateInputSubscribeView() { private fun validateInputSubscribeView() {
if (!this::positiveButton.isInitialized) return // As per crash seen in Google Play if (!this::positiveButton.isInitialized) return // As per crash seen in Google Play
// Show/hide things: This logic is intentionally kept simple. Do not simplify "just because it's pretty".
val instantToggleAllowed = if (!BuildConfig.FIREBASE_AVAILABLE) {
false
} else if (subscribeUseAnotherServerCheckbox.isChecked && subscribeBaseUrlText.text.toString() == appBaseUrl) {
true
} else if (!subscribeUseAnotherServerCheckbox.isChecked && defaultBaseUrl == null) {
true
} else {
false
}
if (subscribeUseAnotherServerCheckbox.isChecked) { if (subscribeUseAnotherServerCheckbox.isChecked) {
subscribeUseAnotherServerDescription.visibility = View.VISIBLE subscribeUseAnotherServerDescription.visibility = View.VISIBLE
subscribeBaseUrlLayout.visibility = View.VISIBLE subscribeBaseUrlLayout.visibility = View.VISIBLE
@ -295,9 +308,15 @@ class AddFragment : DialogFragment() {
subscribeUseAnotherServerDescription.visibility = View.GONE subscribeUseAnotherServerDescription.visibility = View.GONE
subscribeBaseUrlLayout.visibility = View.GONE subscribeBaseUrlLayout.visibility = View.GONE
} }
if (instantToggleAllowed) {
subscribeInstantDeliveryBox.visibility = View.VISIBLE
subscribeInstantDeliveryDescription.visibility = if (subscribeInstantDeliveryCheckbox.isChecked) View.VISIBLE else View.GONE
subscribeForegroundDescription.visibility = View.GONE
} else {
subscribeInstantDeliveryBox.visibility = View.GONE subscribeInstantDeliveryBox.visibility = View.GONE
subscribeInstantDeliveryDescription.visibility = View.GONE subscribeInstantDeliveryDescription.visibility = View.GONE
subscribeForegroundDescription.visibility = View.GONE subscribeForegroundDescription.visibility = if (BuildConfig.FIREBASE_AVAILABLE) View.VISIBLE else View.GONE
}
// Enable/disable "Subscribe" button // Enable/disable "Subscribe" button
lifecycleScope.launch(Dispatchers.IO) { lifecycleScope.launch(Dispatchers.IO) {
@ -337,7 +356,7 @@ class AddFragment : DialogFragment() {
activity.runOnUiThread { activity.runOnUiThread {
val topic = subscribeTopicText.text.toString() val topic = subscribeTopicText.text.toString()
val baseUrl = getBaseUrl() val baseUrl = getBaseUrl()
val instant = baseUrl != appBaseUrl || subscribeInstantDeliveryCheckbox.isChecked val instant = !BuildConfig.FIREBASE_AVAILABLE || baseUrl != appBaseUrl || subscribeInstantDeliveryCheckbox.isChecked
subscribeListener.onSubscribe(topic, baseUrl, instant) subscribeListener.onSubscribe(topic, baseUrl, instant)
dialog?.dismiss() dialog?.dismiss()
} }

View file

@ -30,6 +30,7 @@ import io.heckel.ntfy.app.Application
import io.heckel.ntfy.db.Notification import io.heckel.ntfy.db.Notification
import io.heckel.ntfy.db.Repository import io.heckel.ntfy.db.Repository
import io.heckel.ntfy.db.Subscription import io.heckel.ntfy.db.Subscription
import io.heckel.ntfy.firebase.FirebaseMessenger
import io.heckel.ntfy.util.Log import io.heckel.ntfy.util.Log
import io.heckel.ntfy.msg.ApiService import io.heckel.ntfy.msg.ApiService
import io.heckel.ntfy.msg.NotificationService import io.heckel.ntfy.msg.NotificationService
@ -46,6 +47,7 @@ class DetailActivity : AppCompatActivity(), ActionMode.Callback, NotificationFra
} }
private val repository by lazy { (application as Application).repository } private val repository by lazy { (application as Application).repository }
private val api = ApiService() private val api = ApiService()
private val messenger = FirebaseMessenger()
private var notifier: NotificationService? = null // Context-dependent private var notifier: NotificationService? = null // Context-dependent
private var appBaseUrl: String? = null // Context-dependent private var appBaseUrl: String? = null // Context-dependent
@ -125,6 +127,12 @@ class DetailActivity : AppCompatActivity(), ActionMode.Callback, NotificationFra
) )
repository.addSubscription(subscription) repository.addSubscription(subscription)
// Subscribe to Firebase topic if ntfy.sh (even if instant, just to be sure!)
if (baseUrl == appBaseUrl) {
Log.d(TAG, "Subscribing to Firebase topic $topic")
messenger.subscribe(topic)
}
// Fetch cached messages // Fetch cached messages
try { try {
val user = repository.getUser(subscription.baseUrl) // May be null val user = repository.getUser(subscription.baseUrl) // May be null
@ -521,10 +529,16 @@ class DetailActivity : AppCompatActivity(), ActionMode.Callback, NotificationFra
val appBaseUrl = getString(R.string.app_base_url) val appBaseUrl = getString(R.string.app_base_url)
val enableInstantItem = menu.findItem(R.id.detail_menu_enable_instant) val enableInstantItem = menu.findItem(R.id.detail_menu_enable_instant)
val disableInstantItem = menu.findItem(R.id.detail_menu_disable_instant) val disableInstantItem = menu.findItem(R.id.detail_menu_disable_instant)
val allowToggleInstant = BuildConfig.FIREBASE_AVAILABLE && subscriptionBaseUrl == appBaseUrl
if (allowToggleInstant) {
enableInstantItem?.isVisible = !subscriptionInstant
disableInstantItem?.isVisible = subscriptionInstant
} else {
enableInstantItem?.isVisible = false enableInstantItem?.isVisible = false
disableInstantItem?.isVisible = false disableInstantItem?.isVisible = false
} }
} }
}
private fun showHideMutedUntilMenuItems(mutedUntilTimestamp: Long) { private fun showHideMutedUntilMenuItems(mutedUntilTimestamp: Long) {
if (!this::menu.isInitialized) { if (!this::menu.isInitialized) {
@ -594,6 +608,9 @@ class DetailActivity : AppCompatActivity(), ActionMode.Callback, NotificationFra
GlobalScope.launch(Dispatchers.IO) { GlobalScope.launch(Dispatchers.IO) {
repository.removeAllNotifications(subscriptionId) repository.removeAllNotifications(subscriptionId)
repository.removeSubscription(subscriptionId) repository.removeSubscription(subscriptionId)
if (subscriptionBaseUrl == appBaseUrl) {
messenger.unsubscribe(subscriptionTopic)
}
} }
finish() finish()
} }

View file

@ -114,6 +114,7 @@ class DetailSettingsActivity : AppCompatActivity() {
private fun loadView() { private fun loadView() {
if (subscription.upAppId == null) { if (subscription.upAppId == null) {
loadInstantPref()
loadMutedUntilPref() loadMutedUntilPref()
loadMinPriorityPref() loadMinPriorityPref()
loadAutoDeletePref() loadAutoDeletePref()
@ -133,6 +134,29 @@ class DetailSettingsActivity : AppCompatActivity() {
loadTopicUrlPref() loadTopicUrlPref()
} }
private fun loadInstantPref() {
val appBaseUrl = getString(R.string.app_base_url)
val prefId = context?.getString(R.string.detail_settings_notifications_instant_key) ?: return
val pref: SwitchPreference? = findPreference(prefId)
pref?.isVisible = BuildConfig.FIREBASE_AVAILABLE && subscription.baseUrl == appBaseUrl
pref?.isChecked = subscription.instant
pref?.preferenceDataStore = object : PreferenceDataStore() {
override fun putBoolean(key: String?, value: Boolean) {
save(subscription.copy(instant = value), refresh = true)
}
override fun getBoolean(key: String?, defValue: Boolean): Boolean {
return subscription.instant
}
}
pref?.summaryProvider = Preference.SummaryProvider<SwitchPreference> { preference ->
if (preference.isChecked) {
getString(R.string.detail_settings_notifications_instant_summary_on)
} else {
getString(R.string.detail_settings_notifications_instant_summary_off)
}
}
}
private fun loadDedicatedChannelsPrefs() { private fun loadDedicatedChannelsPrefs() {
val prefId = context?.getString(R.string.detail_settings_notifications_dedicated_channels_key) ?: return val prefId = context?.getString(R.string.detail_settings_notifications_dedicated_channels_key) ?: return
val pref: SwitchPreference? = findPreference(prefId) val pref: SwitchPreference? = findPreference(prefId)

View file

@ -34,6 +34,7 @@ import io.heckel.ntfy.R
import io.heckel.ntfy.app.Application import io.heckel.ntfy.app.Application
import io.heckel.ntfy.db.Repository import io.heckel.ntfy.db.Repository
import io.heckel.ntfy.db.Subscription import io.heckel.ntfy.db.Subscription
import io.heckel.ntfy.firebase.FirebaseMessenger
import io.heckel.ntfy.msg.ApiService import io.heckel.ntfy.msg.ApiService
import io.heckel.ntfy.msg.DownloadManager import io.heckel.ntfy.msg.DownloadManager
import io.heckel.ntfy.msg.DownloadType import io.heckel.ntfy.msg.DownloadType
@ -56,6 +57,7 @@ class MainActivity : AppCompatActivity(), ActionMode.Callback, AddFragment.Subsc
} }
private val repository by lazy { (application as Application).repository } private val repository by lazy { (application as Application).repository }
private val api = ApiService() private val api = ApiService()
private val messenger = FirebaseMessenger()
// UI elements // UI elements
private lateinit var menu: Menu private lateinit var menu: Menu
@ -197,6 +199,9 @@ class MainActivity : AppCompatActivity(), ActionMode.Callback, AddFragment.Subsc
// Create notification channels right away, so we can configure them immediately after installing the app // Create notification channels right away, so we can configure them immediately after installing the app
dispatcher?.init() dispatcher?.init()
// Subscribe to control Firebase channel (so we can re-start the foreground service if it dies)
messenger.subscribe(ApiService.CONTROL_TOPIC)
// Darrkkkk mode // Darrkkkk mode
AppCompatDelegate.setDefaultNightMode(repository.getDarkMode()) AppCompatDelegate.setDefaultNightMode(repository.getDarkMode())
@ -207,36 +212,6 @@ class MainActivity : AppCompatActivity(), ActionMode.Callback, AddFragment.Subsc
// Permissions // Permissions
maybeRequestNotificationPermission() maybeRequestNotificationPermission()
lifecycleScope.launch(Dispatchers.IO) {
val subscriptions = repository.getSubscriptions()
val defaultTopic = "murena_notification"
val hasTestTopic = subscriptions.any { it.topic == defaultTopic }
if (hasTestTopic) {
return@launch
}
val subscription = Subscription(
id = randomSubscriptionId(),
baseUrl = appBaseUrl!!,
topic = defaultTopic,
instant = true,
dedicatedChannels = false,
mutedUntil = 0,
minPriority = Repository.MIN_PRIORITY_USE_GLOBAL,
autoDelete = Repository.AUTO_DELETE_USE_GLOBAL,
insistent = Repository.INSISTENT_MAX_PRIORITY_USE_GLOBAL,
lastNotificationId = null,
icon = null,
upAppId = null,
upConnectorToken = null,
displayName = null,
totalCount = 0,
newCount = 0,
lastActive = Date().time / 1000
)
viewModel.add(subscription)
}
} }
private fun maybeRequestNotificationPermission() { private fun maybeRequestNotificationPermission() {
@ -374,6 +349,10 @@ class MainActivity : AppCompatActivity(), ActionMode.Callback, AddFragment.Subsc
} }
val mutedUntilSeconds = repository.getGlobalMutedUntil() val mutedUntilSeconds = repository.getGlobalMutedUntil()
runOnUiThread { runOnUiThread {
// Show/hide in-app rate widget
val rateAppItem = menu.findItem(R.id.main_menu_rate)
rateAppItem.isVisible = BuildConfig.RATE_APP_AVAILABLE
// Pause notification icons // Pause notification icons
val notificationsEnabledItem = menu.findItem(R.id.main_menu_notifications_enabled) val notificationsEnabledItem = menu.findItem(R.id.main_menu_notifications_enabled)
val notificationsDisabledUntilItem = menu.findItem(R.id.main_menu_notifications_disabled_until) val notificationsDisabledUntilItem = menu.findItem(R.id.main_menu_notifications_disabled_until)
@ -406,7 +385,26 @@ class MainActivity : AppCompatActivity(), ActionMode.Callback, AddFragment.Subsc
startActivity(Intent(this, SettingsActivity::class.java)) startActivity(Intent(this, SettingsActivity::class.java))
true true
} }
R.id.main_menu_report_bug -> {
startActivity(Intent(Intent.ACTION_VIEW, Uri.parse(getString(R.string.main_menu_report_bug_url))))
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")))
}
true
}
R.id.main_menu_donate -> {
startActivity(Intent(Intent.ACTION_VIEW, Uri.parse(getString(R.string.main_menu_donate_url))))
true
}
R.id.main_menu_docs -> {
startActivity(Intent(Intent.ACTION_VIEW, Uri.parse(getString(R.string.main_menu_docs_url))))
true
}
else -> super.onOptionsItemSelected(item) else -> super.onOptionsItemSelected(item)
} }
} }
@ -468,6 +466,12 @@ class MainActivity : AppCompatActivity(), ActionMode.Callback, AddFragment.Subsc
) )
viewModel.add(subscription) viewModel.add(subscription)
// Subscribe to Firebase topic if ntfy.sh (even if instant, just to be sure!)
if (baseUrl == appBaseUrl) {
Log.d(TAG, "Subscribing to Firebase topic $topic")
messenger.subscribe(topic)
}
// Fetch cached messages // Fetch cached messages
lifecycleScope.launch(Dispatchers.IO) { lifecycleScope.launch(Dispatchers.IO) {
try { try {

View file

@ -105,7 +105,7 @@ class MainAdapter(private val repository: Repository, private val onClick: (Subs
dateView.visibility = if (isUnifiedPush) View.GONE else View.VISIBLE dateView.visibility = if (isUnifiedPush) View.GONE else View.VISIBLE
notificationDisabledUntilImageView.visibility = if (showMutedUntilIcon) View.VISIBLE else View.GONE notificationDisabledUntilImageView.visibility = if (showMutedUntilIcon) View.VISIBLE else View.GONE
notificationDisabledForeverImageView.visibility = if (showMutedForeverIcon) View.VISIBLE else View.GONE notificationDisabledForeverImageView.visibility = if (showMutedForeverIcon) View.VISIBLE else View.GONE
instantImageView.visibility = View.GONE instantImageView.visibility = if (subscription.instant && BuildConfig.FIREBASE_AVAILABLE) View.VISIBLE else View.GONE
if (isUnifiedPush || subscription.newCount == 0) { if (isUnifiedPush || subscription.newCount == 0) {
newItemsView.visibility = View.GONE newItemsView.visibility = View.GONE
} else { } else {

View file

@ -0,0 +1,71 @@
package io.heckel.ntfy.ui
import android.content.res.Configuration
import android.os.Build
import android.os.Bundle
import android.view.WindowInsetsController
import androidx.annotation.RequiresApi
import androidx.appcompat.app.AppCompatActivity
import io.heckel.ntfy.R
import io.heckel.ntfy.databinding.MainSettingsActivityBinding
class MainSettingsActivity : AppCompatActivity() {
private lateinit var mBinding: MainSettingsActivityBinding
@RequiresApi(Build.VERSION_CODES.R)
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
mBinding = MainSettingsActivityBinding.inflate(layoutInflater)
setContentView(mBinding.root)
setupToolbar()
setSystemBarsAppearance()
showPreferencesFragment()
}
private fun setupToolbar() {
mBinding.toolbar.setNavigationOnClickListener {
onBackPressedDispatcher.onBackPressed()
}
}
@RequiresApi(Build.VERSION_CODES.R)
private fun setSystemBarsAppearance() {
val insetsController = window.insetsController ?: return
val isLightMode = isSystemInLightMode()
if (isLightMode) {
insetsController.setSystemBarsAppearance(
WindowInsetsController.APPEARANCE_LIGHT_STATUS_BARS,
WindowInsetsController.APPEARANCE_LIGHT_STATUS_BARS
)
insetsController.setSystemBarsAppearance(
WindowInsetsController.APPEARANCE_LIGHT_NAVIGATION_BARS,
WindowInsetsController.APPEARANCE_LIGHT_NAVIGATION_BARS
)
} else {
insetsController.setSystemBarsAppearance(
0,
WindowInsetsController.APPEARANCE_LIGHT_STATUS_BARS
)
insetsController.setSystemBarsAppearance(
0,
WindowInsetsController.APPEARANCE_LIGHT_NAVIGATION_BARS
)
}
}
private fun isSystemInLightMode(): Boolean {
val nightModeFlags = resources.configuration.uiMode and Configuration.UI_MODE_NIGHT_MASK
return nightModeFlags != Configuration.UI_MODE_NIGHT_YES
}
private fun showPreferencesFragment() {
supportFragmentManager
.beginTransaction()
.replace(R.id.fragment_container, PreferencesFragment())
.commit()
}
}

View file

@ -0,0 +1,37 @@
package io.heckel.ntfy.ui
import android.content.Context
import android.content.Intent
import android.os.Bundle
import android.view.Menu
import android.view.MenuInflater
import android.view.MenuItem
import android.widget.Toolbar
import androidx.preference.PreferenceFragmentCompat
import androidx.preference.SwitchPreferenceCompat
import io.heckel.ntfy.R
import io.heckel.ntfy.service.SubscriberService
import io.heckel.ntfy.util.Log
class PreferencesFragment : PreferenceFragmentCompat() {
override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) {
setPreferencesFromResource(R.xml.settings_preferences, rootKey)
val preference: SwitchPreferenceCompat? =
findPreference(getString(R.string.eos_preference_key_is_enabled))
preference?.setOnPreferenceChangeListener { _, newValue ->
val isChecked = newValue as Boolean
val intent = Intent(context, SubscriberService::class.java)
intent.action = if (isChecked) {
SubscriberService.Action.START.name
} else {
SubscriberService.Action.STOP.name
}
requireContext().startService(intent)
true
}
}
}

View file

@ -68,7 +68,7 @@ class BroadcastReceiver : android.content.BroadcastReceiver() {
// Add subscription // Add subscription
val baseUrl = repository.getDefaultBaseUrl() ?: context.getString(R.string.app_base_url) val baseUrl = repository.getDefaultBaseUrl() ?: context.getString(R.string.app_base_url)
val topic = "murena_notification" val topic = UP_PREFIX + randomString(TOPIC_RANDOM_ID_LENGTH)
val endpoint = topicUrlUp(baseUrl, topic) val endpoint = topicUrlUp(baseUrl, topic)
val subscription = Subscription( val subscription = Subscription(
id = randomSubscriptionId(), id = randomSubscriptionId(),

View file

@ -1,31 +1,31 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android" <vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="50dp" android:width="22dp"
android:height="50dp" android:height="22dp"
android:viewportWidth="50" android:viewportWidth="50"
android:viewportHeight="50"> android:viewportHeight="50">
<path <path
android:pathData="m7.8399,6.35c-3.58,0 -6.6469,2.817 -6.6469,6.3983v0.003l0.0351,27.8668 -0.8991,6.6347 12.2261,-3.248L42.9487,44.0049c3.58,0 6.6469,-2.8208 6.6469,-6.4022v-24.8545c0,-3.5803 -3.0652,-6.3967 -6.6438,-6.3983h-0.0031zM7.8399,10.8662h35.1088,0.0031c1.2579,0.0013 2.1277,0.9164 2.1277,1.8821v24.8544c0,0.9666 -0.8714,1.8821 -2.1307,1.8821L11.8924,39.4849l-6.2114,1.8768 0.0633,-0.366 -0.0343,-28.2473c0,-0.9665 0.8706,-1.8821 2.13,-1.8821z" android:pathData="m7.8399,6.35c-3.58,0 -6.6469,2.817 -6.6469,6.3983v0.003l0.0351,27.8668 -0.8991,6.6347 12.2261,-3.248L42.9487,44.0049c3.58,0 6.6469,-2.8208 6.6469,-6.4022v-24.8545c0,-3.5803 -3.0652,-6.3967 -6.6438,-6.3983h-0.0031zM7.8399,10.8662h35.1088,0.0031c1.2579,0.0013 2.1277,0.9164 2.1277,1.8821v24.8544c0,0.9666 -0.8714,1.8821 -2.1307,1.8821L11.8924,39.4849l-6.2114,1.8768 0.0633,-0.366 -0.0343,-28.2473c0,-0.9665 0.8706,-1.8821 2.13,-1.8821z"
android:strokeWidth="0.754022" android:strokeWidth="0.754022"
android:fillColor="#FFFFFFFF" android:fillColor="?android:attr/colorControlNormal"
android:strokeColor="#00000000"/> android:strokeColor="#00000000"/>
<path <path
android:pathData="m11.5278,32.0849l0,-3.346l7.0363,-3.721q0.3397,-0.1732 0.6551,-0.2596 0.3397,-0.1153 0.6066,-0.1732 0.2912,-0.0288 0.5823,-0.0576l0,-0.2308q-0.2912,-0.0288 -0.5823,-0.1153 -0.2669,-0.0576 -0.6066,-0.1443 -0.3154,-0.1153 -0.6551,-0.2884l-7.0363,-3.721l0,-3.3749l10.8699,5.9132l0,3.6056z" android:pathData="m11.5278,32.0849l0,-3.346l7.0363,-3.721q0.3397,-0.1732 0.6551,-0.2596 0.3397,-0.1153 0.6066,-0.1732 0.2912,-0.0288 0.5823,-0.0576l0,-0.2308q-0.2912,-0.0288 -0.5823,-0.1153 -0.2669,-0.0576 -0.6066,-0.1443 -0.3154,-0.1153 -0.6551,-0.2884l-7.0363,-3.721l0,-3.3749l10.8699,5.9132l0,3.6056z"
android:strokeWidth="0.525121" android:strokeWidth="0.525121"
android:fillColor="#FFFFFFFF" android:fillColor="?android:attr/colorControlNormal"
android:strokeColor="#00000000"/> android:strokeColor="#00000000"/>
<path <path
android:pathData="m10.9661,15.6112l0,4.8516l7.3742,3.9002c0.0157,0.0077 0.0305,0.0128 0.0461,0.0204 -0.0157,0.0077 -0.0305,0.0128 -0.0461,0.0204l-7.3742,3.9002l0,4.8267l0.7961,-0.4333 11.1995,-6.0969l0,-4.463zM12.0931,17.6933 L21.8346,22.9981l0,2.7446l-9.7414,5.2999l0,-1.8679l6.6912,-3.5416 0.0084,-0.0051c0.1961,-0.0992 0.3826,-0.1724 0.5531,-0.2191l0.0127,0l0.0167,-0.0051c0.2034,-0.0691 0.3777,-0.1209 0.5279,-0.1545l1.0684,-0.1046l0,-1.4644l-0.5154,-0.0497c-0.1632,-0.0153 -0.3288,-0.0505 -0.4944,-0.0997l-0.0167,-0.0051 -0.0167,-0.0051c-0.1632,-0.0352 -0.3552,-0.0811 -0.5656,-0.1344 -0.1802,-0.0668 -0.3706,-0.1479 -0.5698,-0.2492l-0.0084,-0.0051 -6.6912,-3.5416z" android:pathData="m10.9661,15.6112l0,4.8516l7.3742,3.9002c0.0157,0.0077 0.0305,0.0128 0.0461,0.0204 -0.0157,0.0077 -0.0305,0.0128 -0.0461,0.0204l-7.3742,3.9002l0,4.8267l0.7961,-0.4333 11.1995,-6.0969l0,-4.463zM12.0931,17.6933 L21.8346,22.9981l0,2.7446l-9.7414,5.2999l0,-1.8679l6.6912,-3.5416 0.0084,-0.0051c0.1961,-0.0992 0.3826,-0.1724 0.5531,-0.2191l0.0127,0l0.0167,-0.0051c0.2034,-0.0691 0.3777,-0.1209 0.5279,-0.1545l1.0684,-0.1046l0,-1.4644l-0.5154,-0.0497c-0.1632,-0.0153 -0.3288,-0.0505 -0.4944,-0.0997l-0.0167,-0.0051 -0.0167,-0.0051c-0.1632,-0.0352 -0.3552,-0.0811 -0.5656,-0.1344 -0.1802,-0.0668 -0.3706,-0.1479 -0.5698,-0.2492l-0.0084,-0.0051 -6.6912,-3.5416z"
android:strokeWidth="0.525121" android:strokeWidth="0.525121"
android:fillColor="#FFFFFFFF" android:fillColor="?android:attr/colorControlNormal"
android:strokeColor="#00000000"/> android:strokeColor="#00000000"/>
<path <path
android:pathData="m26.7503,30.9206l11.6118,0l0,3.1388L26.7503,34.0594Z" android:pathData="m26.7503,30.9206l11.6118,0l0,3.1388L26.7503,34.0594Z"
android:strokeWidth="0.525121" android:strokeWidth="0.525121"
android:fillColor="#FFFFFFFF" android:fillColor="?android:attr/colorControlNormal"
android:strokeColor="#00000000"/> android:strokeColor="#00000000"/>
<path <path
android:pathData="m26.1875,30.2775l0,0.6427 0,3.7845l12.7371,0l0,-4.4272zM27.3113,31.563l10.4896,0l0,1.8515l-10.4896,0z" android:pathData="m26.1875,30.2775l0,0.6427 0,3.7845l12.7371,0l0,-4.4272zM27.3113,31.563l10.4896,0l0,1.8515l-10.4896,0z"
android:strokeWidth="0.525121" android:strokeWidth="0.525121"
android:fillColor="#FFFFFFFF" android:fillColor="?android:attr/colorControlNormal"
android:strokeColor="#00000000"/> android:strokeColor="#00000000"/>
</vector> </vector>

View file

@ -0,0 +1,49 @@
<?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:orientation="vertical"
android:fitsSystemWindows="true"
android:layout_width="match_parent"
android:layout_height="match_parent">
<com.google.android.material.appbar.AppBarLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:fitsSystemWindows="true">
<com.google.android.material.appbar.CollapsingToolbarLayout
android:id="@+id/collapsing_toolbar"
style="?attr/collapsingToolbarLayoutLargeStyle"
android:background="@color/e_background"
android:layout_width="match_parent"
app:layout_scrollFlags="scroll|exitUntilCollapsed|snap"
android:layout_height="?attr/collapsingToolbarLayoutLargeSize">
<com.google.android.material.appbar.MaterialToolbar
app:navigationIcon="@drawable/e_ic_back"
app:title="@string/eos_settings_title"
android:id="@+id/toolbar"
android:background="@color/e_background"
android:layout_width="match_parent"
android:layout_height="?attr/actionBarSize"
app:layout_collapseMode="pin" />
</com.google.android.material.appbar.CollapsingToolbarLayout>
</com.google.android.material.appbar.AppBarLayout>
<androidx.core.widget.NestedScrollView
android:layout_width="match_parent"
android:layout_height="match_parent"
android:fillViewport="true"
app:layout_behavior="@string/appbar_scrolling_view_behavior">
<FrameLayout
android:id="@+id/fragment_container"
android:layout_width="match_parent"
android:layout_height="match_parent"/>
</androidx.core.widget.NestedScrollView>
</LinearLayout>

View file

@ -6,4 +6,8 @@
<item android:id="@+id/main_menu_notifications_disabled_forever" android:title="@string/detail_menu_notifications_disabled_forever" <item android:id="@+id/main_menu_notifications_disabled_forever" android:title="@string/detail_menu_notifications_disabled_forever"
app:showAsAction="ifRoom" android:icon="@drawable/ic_notifications_off_white_outline_24dp"/> app:showAsAction="ifRoom" android:icon="@drawable/ic_notifications_off_white_outline_24dp"/>
<item android:id="@+id/main_menu_settings" android:title="@string/main_menu_settings_title"/> <item android:id="@+id/main_menu_settings" android:title="@string/main_menu_settings_title"/>
<item android:id="@+id/main_menu_docs" android:title="@string/main_menu_docs_title"/>
<item android:id="@+id/main_menu_rate" android:title="@string/main_menu_rate_title"/>
<item android:id="@+id/main_menu_donate" android:title="@string/main_menu_donate_title"/>
<item android:id="@+id/main_menu_report_bug" android:title="@string/main_menu_report_bug_title"/>
</menu> </menu>

View file

@ -343,4 +343,6 @@
<string name="settings_advanced_unifiedpush_summary_disabled">ntfy arbeitet nicht als UnifiedPush-Distributor</string> <string name="settings_advanced_unifiedpush_summary_disabled">ntfy arbeitet nicht als UnifiedPush-Distributor</string>
<string name="settings_advanced_unifiedpush_title">UnifiedPush aktivieren</string> <string name="settings_advanced_unifiedpush_title">UnifiedPush aktivieren</string>
<string name="settings_advanced_unifiedpush_summary_enabled">ntfy arbeitet als UnifiedPush-Distributor</string> <string name="settings_advanced_unifiedpush_summary_enabled">ntfy arbeitet als UnifiedPush-Distributor</string>
<string name="eos_settings_enable_title">Den Verteiler aktivieren</string>
<string name="eos_settings_enable_description">Es ermöglicht Drittanbieteranwendungen, UnifiedPush-Benachrichtigungen zu empfangen</string>
</resources> </resources>

View file

@ -343,4 +343,6 @@
<string name="settings_advanced_unifiedpush_title">Activar UnifiedPush</string> <string name="settings_advanced_unifiedpush_title">Activar UnifiedPush</string>
<string name="settings_advanced_unifiedpush_summary_enabled">ntfy actuará como distribuidor UnifiedPush</string> <string name="settings_advanced_unifiedpush_summary_enabled">ntfy actuará como distribuidor UnifiedPush</string>
<string name="settings_advanced_unifiedpush_summary_disabled">ntfy no actuará como distribuidor UnifiedPush</string> <string name="settings_advanced_unifiedpush_summary_disabled">ntfy no actuará como distribuidor UnifiedPush</string>
<string name="eos_settings_enable_title">Habilitar el distribuidor</string>
<string name="eos_settings_enable_description">Permite a las aplicaciones de terceros recibir notificaciones de UnifiedPush</string>
</resources> </resources>

View file

@ -343,4 +343,6 @@
<string name="settings_advanced_unifiedpush_summary_enabled">ntfy agira comme un distributeur UnifiedPush</string> <string name="settings_advanced_unifiedpush_summary_enabled">ntfy agira comme un distributeur UnifiedPush</string>
<string name="settings_advanced_unifiedpush_summary_disabled">ntfy n\'agira pas comme un distributeur UnifiedPush</string> <string name="settings_advanced_unifiedpush_summary_disabled">ntfy n\'agira pas comme un distributeur UnifiedPush</string>
<string name="settings_advanced_unifiedpush_title">Activer le \"UnifiedPush\"</string> <string name="settings_advanced_unifiedpush_title">Activer le \"UnifiedPush\"</string>
<string name="eos_settings_enable_title">Activer le distributeur</string>
<string name="eos_settings_enable_description">Cela permet aux applications tierces de recevoir des notifications UnifiedPush</string>
</resources> </resources>

View file

@ -330,4 +330,6 @@
<string name="channel_notifications_group_default_name">Default</string> <string name="channel_notifications_group_default_name">Default</string>
<string name="main_menu_donate_title">Dona 💸</string> <string name="main_menu_donate_title">Dona 💸</string>
<string name="detail_item_cannot_open_apk">Le app non possono più essere installate: devono essere scaricate via browser. Vedi l\'issue #531 per dettagli.</string> <string name="detail_item_cannot_open_apk">Le app non possono più essere installate: devono essere scaricate via browser. Vedi l\'issue #531 per dettagli.</string>
<string name="eos_settings_enable_title">Abilitare il distributore</string>
<string name="eos_settings_enable_description">Consente alle applicazioni di terze parti di ricevere notifiche UnifiedPush</string>
</resources> </resources>

View file

@ -391,4 +391,10 @@
<string name="user_dialog_button_cancel">Cancel</string> <string name="user_dialog_button_cancel">Cancel</string>
<string name="user_dialog_button_delete">Delete user</string> <string name="user_dialog_button_delete">Delete user</string>
<string name="user_dialog_button_save">Save</string> <string name="user_dialog_button_save">Save</string>
<!-- /e/OS integration preferences -->
<string name="eos_preference_key_is_enabled" translatable="false">isEnabled</string>
<string name="eos_settings_title" translatable="false">UnifiedPush</string>
<string name="eos_settings_enable_title" translatable="true">Enable the distributor</string>
<string name="eos_settings_enable_description" translatable="true">It allows 3rd party applications to receive UnifiedPush notifications</string>
</resources> </resources>

View file

@ -30,4 +30,26 @@
<item name="cornerFamily">rounded</item> <item name="cornerFamily">rounded</item>
<item name="cornerSize">5dp</item> <item name="cornerSize">5dp</item>
</style> </style>
<style name="PreferenceTheme" parent="PreferenceTheme.Base"/>
<style name="PreferenceTheme.Base" parent="Theme.Material3.DayNight.NoActionBar">
<item name="colorPrimary">@color/e_action_bar</item>
<item name="colorPrimaryDark">@color/e_action_bar</item>
<item name="colorAccent">@color/e_accent</item>
<item name="android:textColorPrimary">@color/e_primary_text_color</item>
<item name="android:textColorSecondary">@color/e_secondary_text_color</item>
<item name="android:textColorPrimaryInverse">@color/e_background</item>
<item name="android:windowBackground">@color/e_background</item>
<item name="colorControlActivated">@color/e_accent</item>
<item name="colorButtonNormal">@color/e_icon_color</item>
<item name="colorControlHighlight">@color/e_icon_color</item>
<item name="homeAsUpIndicator">@drawable/e_ic_back</item>
<item name="android:homeAsUpIndicator">@drawable/e_ic_back</item>
<item name="android:popupBackground">@color/e_floating_background</item>
<item name="android:divider">@color/e_divider_color</item>
<item name="android:navigationBarColor">@android:color/transparent</item>
<item name="android:statusBarColor">@color/e_background</item>
<item name="switchStyle">@style/ETheme.Switch</item>
</style>
</resources> </resources>

View file

@ -52,7 +52,7 @@
app:entryValues="@array/settings_general_dark_mode_values" app:entryValues="@array/settings_general_dark_mode_values"
app:defaultValue="-1"/> app:defaultValue="-1"/>
</PreferenceCategory> </PreferenceCategory>
<PreferenceCategory app:title="@string/settings_backup_restore_header" app:isPreferenceVisible="false"> <PreferenceCategory app:title="@string/settings_backup_restore_header">
<ListPreference <ListPreference
app:key="@string/settings_backup_restore_backup_key" app:key="@string/settings_backup_restore_backup_key"
app:title="@string/settings_backup_restore_backup_title" app:title="@string/settings_backup_restore_backup_title"
@ -96,7 +96,7 @@
app:title="@string/settings_advanced_clear_logs_title" app:title="@string/settings_advanced_clear_logs_title"
app:summary="@string/settings_advanced_clear_logs_summary"/> app:summary="@string/settings_advanced_clear_logs_summary"/>
</PreferenceCategory> </PreferenceCategory>
<PreferenceCategory app:title="@string/settings_about_header" app:isPreferenceVisible="false"> <PreferenceCategory app:title="@string/settings_about_header">
<Preference <Preference
app:key="@string/settings_about_version_key" app:key="@string/settings_about_version_key"
app:title="@string/settings_about_version_title"/> app:title="@string/settings_about_version_title"/>

View file

@ -0,0 +1,11 @@
<PreferenceScreen
xmlns:app="http://schemas.android.com/apk/res-auto">
<SwitchPreferenceCompat
app:icon="@drawable/ic_notification"
app:key="@string/eos_preference_key_is_enabled"
app:defaultValue="false"
app:title="@string/eos_settings_enable_title"
app:summary="@string/eos_settings_enable_description" />
</PreferenceScreen>

View file

@ -0,0 +1,36 @@
package io.heckel.ntfy.firebase
import com.google.firebase.messaging.FirebaseMessaging
import io.heckel.ntfy.util.Log
class FirebaseMessenger {
fun subscribe(topic: String) {
val firebase = maybeInstance() ?: return
firebase
.subscribeToTopic(topic)
.addOnCompleteListener {
Log.d(TAG, "Subscribing to topic $topic complete: result=${it.result}, exception=${it.exception}, successful=${it.isSuccessful}")
}
.addOnFailureListener { e ->
Log.e(TAG, "Subscribing to topic $topic failed: ${e.message}", e)
}
}
fun unsubscribe(topic: String) {
val firebase = maybeInstance() ?: return
firebase.unsubscribeFromTopic(topic)
}
private fun maybeInstance(): FirebaseMessaging? {
return try {
FirebaseMessaging.getInstance()
} catch (e: Exception) {
Log.e(TAG, "Firebase instance unavailable: ${e.message}", e)
null
}
}
companion object {
private const val TAG = "NtfyFirebase"
}
}

View file

@ -0,0 +1,166 @@
package io.heckel.ntfy.firebase
import android.content.Intent
import androidx.work.*
import com.google.firebase.messaging.FirebaseMessagingService
import com.google.firebase.messaging.RemoteMessage
import io.heckel.ntfy.R
import io.heckel.ntfy.app.Application
import io.heckel.ntfy.db.Attachment
import io.heckel.ntfy.db.Icon
import io.heckel.ntfy.db.Notification
import io.heckel.ntfy.util.Log
import io.heckel.ntfy.msg.ApiService
import io.heckel.ntfy.msg.NotificationDispatcher
import io.heckel.ntfy.msg.NotificationParser
import io.heckel.ntfy.service.SubscriberService
import io.heckel.ntfy.util.nullIfZero
import io.heckel.ntfy.util.toPriority
import io.heckel.ntfy.util.topicShortUrl
import io.heckel.ntfy.work.PollWorker
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.launch
import kotlin.random.Random
class FirebaseService : FirebaseMessagingService() {
private val repository by lazy { (application as Application).repository }
private val dispatcher by lazy { NotificationDispatcher(this, repository) }
private val job = SupervisorJob()
private val messenger = FirebaseMessenger()
private val parser = NotificationParser()
override fun onMessageReceived(remoteMessage: RemoteMessage) {
// Init log (this is done in all entrypoints)
Log.init(this)
// We only process data messages
if (remoteMessage.data.isEmpty()) {
Log.d(TAG, "Discarding unexpected message (1): from=${remoteMessage.from}")
return
}
// Dispatch event
val data = remoteMessage.data
when (data["event"]) {
ApiService.EVENT_MESSAGE -> handleMessage(remoteMessage)
ApiService.EVENT_KEEPALIVE -> handleKeepalive(remoteMessage)
ApiService.EVENT_POLL_REQUEST -> handlePollRequest(remoteMessage)
else -> Log.d(TAG, "Discarding unexpected message (2): from=${remoteMessage.from}, data=${data}")
}
}
private fun handleKeepalive(remoteMessage: RemoteMessage) {
Log.d(TAG, "Keepalive received, sending auto restart broadcast for foregrounds service")
sendBroadcast(Intent(this, SubscriberService.AutoRestartReceiver::class.java)) // Restart it if necessary!
val topic = remoteMessage.data["topic"]
if (topic != ApiService.CONTROL_TOPIC) {
Log.d(TAG, "Keepalive on non-control topic $topic received, subscribing to control topic ${ApiService.CONTROL_TOPIC}")
messenger.subscribe(ApiService.CONTROL_TOPIC)
}
}
private fun handlePollRequest(remoteMessage: RemoteMessage) {
val baseUrl = getString(R.string.app_base_url) // Everything from Firebase comes from main service URL!
val topic = remoteMessage.data["topic"] ?: return
val constraints = Constraints.Builder()
.setRequiredNetworkType(NetworkType.CONNECTED)
.build()
val workName = "${PollWorker.WORK_NAME_ONCE_SINGE_PREFIX}_${baseUrl}_${topic}"
val workManager = WorkManager.getInstance(this)
val workRequest = OneTimeWorkRequest.Builder(PollWorker::class.java)
.setInputData(workDataOf(
PollWorker.INPUT_DATA_BASE_URL to baseUrl,
PollWorker.INPUT_DATA_TOPIC to topic
))
.setConstraints(constraints)
.build()
Log.d(TAG, "Poll request for ${topicShortUrl(baseUrl, topic)} received, scheduling unique poll worker with name $workName")
workManager.enqueueUniqueWork(workName, ExistingWorkPolicy.REPLACE, workRequest)
}
private fun handleMessage(remoteMessage: RemoteMessage) {
val data = remoteMessage.data
val id = data["id"]
val timestamp = data["time"]?.toLongOrNull()
val topic = data["topic"]
val title = data["title"]
val message = data["message"]
val priority = data["priority"]?.toIntOrNull()
val tags = data["tags"]
val click = data["click"]
val iconUrl = data["icon"]
val actions = data["actions"] // JSON array as string, sigh ...
val encoding = data["encoding"]
val attachmentName = data["attachment_name"] ?: "attachment.bin"
val attachmentType = data["attachment_type"]
val attachmentSize = data["attachment_size"]?.toLongOrNull()?.nullIfZero()
val attachmentExpires = data["attachment_expires"]?.toLongOrNull()?.nullIfZero()
val attachmentUrl = data["attachment_url"]
val truncated = (data["truncated"] ?: "") == "1"
if (id == null || topic == null || message == null || timestamp == null) {
Log.d(TAG, "Discarding unexpected message: from=${remoteMessage.from}, fcmprio=${remoteMessage.priority}, fcmprio_orig=${remoteMessage.originalPriority}, data=${data}")
return
}
Log.d(TAG, "Received message: from=${remoteMessage.from}, fcmprio=${remoteMessage.priority}, fcmprio_orig=${remoteMessage.originalPriority}, data=${data}")
CoroutineScope(job).launch {
val baseUrl = getString(R.string.app_base_url) // Everything from Firebase comes from main service URL!
// Check if notification was truncated and discard if it will (or likely already did) arrive via instant delivery
val subscription = repository.getSubscription(baseUrl, topic) ?: return@launch
if (truncated && subscription.instant) {
Log.d(TAG, "Discarding truncated message that did/will arrive via instant delivery: from=${remoteMessage.from}, fcmprio=${remoteMessage.priority}, fcmprio_orig=${remoteMessage.originalPriority}, data=${data}")
return@launch
}
// Add notification
val attachment = if (attachmentUrl != null) {
Attachment(
name = attachmentName,
type = attachmentType,
size = attachmentSize,
expires = attachmentExpires,
url = attachmentUrl,
)
} else null
val icon: Icon? = if (iconUrl != null && iconUrl != "") Icon(url = iconUrl) else null
val notification = Notification(
id = id,
subscriptionId = subscription.id,
timestamp = timestamp,
title = title ?: "",
message = message,
encoding = encoding ?: "",
priority = toPriority(priority),
tags = tags ?: "",
click = click ?: "",
icon = icon,
actions = parser.parseActions(actions),
attachment = attachment,
notificationId = Random.nextInt(),
deleted = false
)
if (repository.addNotification(notification)) {
Log.d(TAG, "Dispatching notification: from=${remoteMessage.from}, fcmprio=${remoteMessage.priority}, fcmprio_orig=${remoteMessage.originalPriority}, data=${data}")
dispatcher.dispatch(subscription, notification)
}
}
}
override fun onNewToken(token: String) {
// Called if the FCM registration token is updated
// We don't actually use or care about the token, since we're using topics
Log.d(TAG, "Registration token was updated: $token")
}
override fun onDestroy() {
super.onDestroy()
job.cancel()
}
companion object {
private const val TAG = "NtfyFirebase"
}
}

View file

@ -18,6 +18,7 @@ allprojects {
repositories { repositories {
google() google()
mavenCentral() mavenCentral()
maven { url 'https://gitlab.e.foundation/api/v4/groups/9/-/packages/maven'}
maven { url "https://jitpack.io" } // For StfalconImageViewer maven { url "https://jitpack.io" } // For StfalconImageViewer
} }
} }

View file

@ -1,3 +1,2 @@
rootProject.name='ntfy' rootProject.name='ntfy'
include ':app' include ':app'
include ':ui'

1
ui/.gitignore vendored
View file

@ -1 +0,0 @@
/build

View file

@ -1,45 +0,0 @@
plugins {
id 'com.android.application'
id 'org.jetbrains.kotlin.android'
}
android {
namespace 'foundation.e.ntefy.ui'
compileSdk 34
defaultConfig {
applicationId "foundation.e.ntefy.ui"
minSdk 29
targetSdk 34
versionCode 1
versionName "1.0"
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
}
buildTypes {
release {
minifyEnabled false
proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro'
}
}
compileOptions {
sourceCompatibility JavaVersion.VERSION_1_8
targetCompatibility JavaVersion.VERSION_1_8
}
kotlinOptions {
jvmTarget = '1.8'
}
}
dependencies {
implementation 'androidx.core:core-ktx:1.13.1'
implementation 'androidx.appcompat:appcompat:1.6.1'
implementation 'com.google.android.material:material:1.12.0'
testImplementation 'junit:junit:4.13.2'
androidTestImplementation 'androidx.test.ext:junit:1.2.1'
androidTestImplementation 'androidx.test.espresso:espresso-core:3.6.1'
implementation("com.github.UnifiedPush:android-connector:2.2.0")
}

21
ui/proguard-rules.pro vendored
View file

@ -1,21 +0,0 @@
# Add project specific ProGuard rules here.
# You can control the set of applied configuration files using the
# proguardFiles setting in build.gradle.
#
# For more details, see
# http://developer.android.com/guide/developing/tools/proguard.html
# If your project uses WebView with JS, uncomment the following
# and specify the fully qualified class name to the JavaScript interface
# class:
#-keepclassmembers class fqcn.of.javascript.interface.for.webview {
# public *;
#}
# Uncomment this to preserve the line number information for
# debugging stack traces.
#-keepattributes SourceFile,LineNumberTable
# If you keep the line number information, uncomment this to
# hide the original source file name.
#-renamesourcefileattribute SourceFile

View file

@ -1,24 +0,0 @@
package foundation.e.ntefy.ui
import androidx.test.platform.app.InstrumentationRegistry
import androidx.test.ext.junit.runners.AndroidJUnit4
import org.junit.Test
import org.junit.runner.RunWith
import org.junit.Assert.*
/**
* Instrumented test, which will execute on an Android device.
*
* See [testing documentation](http://d.android.com/tools/testing).
*/
@RunWith(AndroidJUnit4::class)
class ExampleInstrumentedTest {
@Test
fun useAppContext() {
// Context of the app under test.
val appContext = InstrumentationRegistry.getInstrumentation().targetContext
assertEquals("foundation.e.ntefy.ui", appContext.packageName)
}
}

View file

@ -1,26 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<application
android:allowBackup="true"
android:icon="@mipmap/ic_launcher"
android:label="@string/app_name"
android:roundIcon="@mipmap/ic_launcher_round"
android:supportsRtl="true"
android:theme="@style/Theme.Ntfy">
<activity android:exported="true" android:name=".MainActivity">
<intent-filter>
<action android:name="android.intent.action.MAIN"/>
<category android:name="android.intent.category.LAUNCHER"/>
</intent-filter>
</activity>
<receiver android:exported="true" android:enabled="true" android:name=".UnifiedPocReceiver">
<intent-filter>
<action android:name="org.unifiedpush.android.connector.MESSAGE"/>
<action android:name="org.unifiedpush.android.connector.UNREGISTERED"/>
<action android:name="org.unifiedpush.android.connector.NEW_ENDPOINT"/>
<action android:name="org.unifiedpush.android.connector.REGISTRATION_FAILED"/>
</intent-filter>
</receiver>
</application>
</manifest>

View file

@ -1,14 +0,0 @@
package foundation.e.ntefy.ui
import androidx.appcompat.app.AppCompatActivity
import android.os.Bundle
import org.unifiedpush.android.connector.UnifiedPush
class MainActivity : AppCompatActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_main)
UnifiedPush.registerAppWithDialog(this)
}
}

View file

@ -1,24 +0,0 @@
package foundation.e.ntefy.ui
import android.content.Context
import android.util.Log
import org.unifiedpush.android.connector.MessagingReceiver
class UnifiedPocReceiver: MessagingReceiver() {
companion object {
const val TAG = "UnifiedPocReceiver"
}
override fun onMessage(context: Context, message: ByteArray, instance: String) {
super.onMessage(context, message, instance)
val utf8Message = String(message)
Log.i(TAG, "onMessage $utf8Message")
}
override fun onNewEndpoint(context: Context, endpoint: String, instance: String) {
super.onNewEndpoint(context, endpoint, instance)
Log.i(TAG, "onNewEndpoint $endpoint")
}
}

View file

@ -1,170 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="108dp"
android:height="108dp"
android:viewportWidth="108"
android:viewportHeight="108">
<path
android:fillColor="#3DDC84"
android:pathData="M0,0h108v108h-108z" />
<path
android:fillColor="#00000000"
android:pathData="M9,0L9,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M19,0L19,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M29,0L29,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M39,0L39,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M49,0L49,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M59,0L59,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M69,0L69,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M79,0L79,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M89,0L89,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M99,0L99,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,9L108,9"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,19L108,19"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,29L108,29"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,39L108,39"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,49L108,49"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,59L108,59"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,69L108,69"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,79L108,79"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,89L108,89"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,99L108,99"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M19,29L89,29"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M19,39L89,39"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M19,49L89,49"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M19,59L89,59"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M19,69L89,69"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M19,79L89,79"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M29,19L29,89"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M39,19L39,89"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M49,19L49,89"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M59,19L59,89"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M69,19L69,89"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M79,19L79,89"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
</vector>

View file

@ -1,30 +0,0 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:aapt="http://schemas.android.com/aapt"
android:width="108dp"
android:height="108dp"
android:viewportWidth="108"
android:viewportHeight="108">
<path android:pathData="M31,63.928c0,0 6.4,-11 12.1,-13.1c7.2,-2.6 26,-1.4 26,-1.4l38.1,38.1L107,108.928l-32,-1L31,63.928z">
<aapt:attr name="android:fillColor">
<gradient
android:endX="85.84757"
android:endY="92.4963"
android:startX="42.9492"
android:startY="49.59793"
android:type="linear">
<item
android:color="#44000000"
android:offset="0.0" />
<item
android:color="#00000000"
android:offset="1.0" />
</gradient>
</aapt:attr>
</path>
<path
android:fillColor="#FFFFFF"
android:fillType="nonZero"
android:pathData="M65.3,45.828l3.8,-6.6c0.2,-0.4 0.1,-0.9 -0.3,-1.1c-0.4,-0.2 -0.9,-0.1 -1.1,0.3l-3.9,6.7c-6.3,-2.8 -13.4,-2.8 -19.7,0l-3.9,-6.7c-0.2,-0.4 -0.7,-0.5 -1.1,-0.3C38.8,38.328 38.7,38.828 38.9,39.228l3.8,6.6C36.2,49.428 31.7,56.028 31,63.928h46C76.3,56.028 71.8,49.428 65.3,45.828zM43.4,57.328c-0.8,0 -1.5,-0.5 -1.8,-1.2c-0.3,-0.7 -0.1,-1.5 0.4,-2.1c0.5,-0.5 1.4,-0.7 2.1,-0.4c0.7,0.3 1.2,1 1.2,1.8C45.3,56.528 44.5,57.328 43.4,57.328L43.4,57.328zM64.6,57.328c-0.8,0 -1.5,-0.5 -1.8,-1.2s-0.1,-1.5 0.4,-2.1c0.5,-0.5 1.4,-0.7 2.1,-0.4c0.7,0.3 1.2,1 1.2,1.8C66.5,56.528 65.6,57.328 64.6,57.328L64.6,57.328z"
android:strokeWidth="1"
android:strokeColor="#00000000" />
</vector>

View file

@ -1,7 +0,0 @@
<?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"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent"
tools:context=".MainActivity"/>

View file

@ -1,6 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@drawable/ic_launcher_background" />
<foreground android:drawable="@drawable/ic_launcher_foreground" />
<monochrome android:drawable="@drawable/ic_launcher_foreground" />
</adaptive-icon>

View file

@ -1,6 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@drawable/ic_launcher_background" />
<foreground android:drawable="@drawable/ic_launcher_foreground" />
<monochrome android:drawable="@drawable/ic_launcher_foreground" />
</adaptive-icon>

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 982 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.6 KiB

View file

@ -1,16 +0,0 @@
<resources xmlns:tools="http://schemas.android.com/tools">
<!-- Base application theme. -->
<style name="Theme.Ntfy" parent="Theme.MaterialComponents.DayNight.DarkActionBar">
<!-- Primary brand color. -->
<item name="colorPrimary">@color/purple_200</item>
<item name="colorPrimaryVariant">@color/purple_700</item>
<item name="colorOnPrimary">@color/black</item>
<!-- Secondary brand color. -->
<item name="colorSecondary">@color/teal_200</item>
<item name="colorSecondaryVariant">@color/teal_200</item>
<item name="colorOnSecondary">@color/black</item>
<!-- Status bar color. -->
<item name="android:statusBarColor">?attr/colorPrimaryVariant</item>
<!-- Customize your theme here. -->
</style>
</resources>

View file

@ -1,10 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<color name="purple_200">#FFBB86FC</color>
<color name="purple_500">#FF6200EE</color>
<color name="purple_700">#FF3700B3</color>
<color name="teal_200">#FF03DAC5</color>
<color name="teal_700">#FF018786</color>
<color name="black">#FF000000</color>
<color name="white">#FFFFFFFF</color>
</resources>

View file

@ -1,3 +0,0 @@
<resources>
<string name="app_name">UI</string>
</resources>

View file

@ -1,16 +0,0 @@
<resources xmlns:tools="http://schemas.android.com/tools">
<!-- Base application theme. -->
<style name="Theme.Ntfy" parent="Theme.MaterialComponents.DayNight.DarkActionBar">
<!-- Primary brand color. -->
<item name="colorPrimary">@color/purple_500</item>
<item name="colorPrimaryVariant">@color/purple_700</item>
<item name="colorOnPrimary">@color/white</item>
<!-- Secondary brand color. -->
<item name="colorSecondary">@color/teal_200</item>
<item name="colorSecondaryVariant">@color/teal_700</item>
<item name="colorOnSecondary">@color/black</item>
<!-- Status bar color. -->
<item name="android:statusBarColor">?attr/colorPrimaryVariant</item>
<!-- Customize your theme here. -->
</style>
</resources>

View file

@ -1,17 +0,0 @@
package foundation.e.ntefy.ui
import org.junit.Test
import org.junit.Assert.*
/**
* Example local unit test, which will execute on the development machine (host).
*
* See [testing documentation](http://d.android.com/tools/testing).
*/
class ExampleUnitTest {
@Test
fun addition_isCorrect() {
assertEquals(4, 2 + 2)
}
}