Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add support for new UnifiedPush specifications, and fallback to the previous ones. #98

Open
wants to merge 9 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 9 additions & 5 deletions app/build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -6,13 +6,17 @@ apply plugin: 'kotlin-android'
apply plugin: 'kotlin-kapt'
apply plugin: 'com.google.gms.google-services'

kotlin {
jvmToolchain(17)
}

android {
compileSdkVersion 33
compileSdkVersion 34

defaultConfig {
applicationId "io.heckel.ntfy"
minSdkVersion 21
targetSdkVersion 33
targetSdkVersion 34

versionCode 33
versionName "1.17.0"
Expand Down Expand Up @@ -54,12 +58,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
]
Expand Down
26 changes: 24 additions & 2 deletions app/src/main/AndroidManifest.xml
Original file line number Diff line number Diff line change
Expand Up @@ -5,11 +5,12 @@
<!-- 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" />
<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 -->
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" android:maxSdkVersion="28"/> <!-- Only required on SDK <= 28 -->
<uses-permission android:name="android.permission.SCHEDULE_EXACT_ALARM"/> <!-- To reschedule the websocket retry -->
<uses-permission android:name="android.permission.SCHEDULE_EXACT_ALARM" android:maxSdkVersion="32"/> <!-- To reschedule the websocket retry -->
<uses-permission android:name="android.permission.POST_NOTIFICATIONS"/> <!-- As of Android 13, we need to ask for permission to post notifications -->

<!--
Expand All @@ -20,6 +21,12 @@
-->
<uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES"/>

<queries>
<intent>
<action android:name="org.unifiedpush.android.connector.RAISE_TO_FOREGROUND" />
</intent>
</queries>

<application
android:name=".app.Application"
android:allowBackup="true"
Expand Down Expand Up @@ -93,9 +100,24 @@
android:name=".msg.NotificationService$ViewActionWithClearActivity"
android:exported="false">
</activity>
<activity android:name=".up.LinkActivity"
android:exported="true">
<intent-filter>
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<data android:scheme="unifiedpush"
android:host="link" />
</intent-filter>
</activity>

<!-- Subscriber foreground service for hosts other than ntfy.sh -->
<service android:name=".service.SubscriberService"/>
<service android:name=".service.SubscriberService"
android:enabled="true"
android:foregroundServiceType="specialUse">
<property android:name="android.app.PROPERTY_SPECIAL_USE_FGS_SUBTYPE"
android:value="This service needs to be constantly connected to the server with the websocket if the device don't have Play Services, or the self-hosted server doesn't support FCM."/>
</service>


<!-- Subscriber service restart on reboot -->
<receiver
Expand Down
18 changes: 17 additions & 1 deletion app/src/main/java/io/heckel/ntfy/service/WsConnection.kt
Original file line number Diff line number Diff line change
Expand Up @@ -114,7 +114,23 @@ 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 < 31 || alarmManager.canScheduleExactAlarms()) {
alarmManager.setExact(
AlarmManager.RTC_WAKEUP,
reconnectTime.timeInMillis,
RECONNECT_TAG,
{ start() },
null
)
} else {
alarmManager.set(
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())
Expand Down
92 changes: 87 additions & 5 deletions app/src/main/java/io/heckel/ntfy/up/BroadcastReceiver.kt
Original file line number Diff line number Diff line change
@@ -1,7 +1,11 @@
package io.heckel.ntfy.up

import android.app.PendingIntent
import android.content.Context
import android.content.Intent
import android.content.pm.PackageManager
import android.os.Build
import androidx.annotation.RequiresApi
import io.heckel.ntfy.R
import io.heckel.ntfy.app.Application
import io.heckel.ntfy.db.Repository
Expand Down Expand Up @@ -32,20 +36,21 @@ class BroadcastReceiver : android.content.BroadcastReceiver() {
}

private fun register(context: Context, intent: Intent) {
val appId = intent.getStringExtra(EXTRA_APPLICATION) ?: return
val appId = getApplication(context, intent) ?: return
val connectorToken = intent.getStringExtra(EXTRA_TOKEN) ?: return
val app = context.applicationContext as Application
val repository = app.repository
val distributor = Distributor(app)
Log.d(TAG, "REGISTER received for app $appId (connectorToken=$connectorToken)")
if (!repository.getUnifiedPushEnabled()) {
Log.w(TAG, "Refusing registration because 'EnableUP' is disabled")
distributor.sendRegistrationFailed(appId, connectorToken, "UnifiedPush is disabled in ntfy")
// Action required: tell the app to not try again before an action as be done manually
// by the user
distributor.sendRegistrationFailed(appId, connectorToken, FailedReason.ACTION_REQUIRED)
return
}
if (appId.isBlank()) {
Log.w(TAG, "Refusing registration: Empty application")
distributor.sendRegistrationFailed(appId, connectorToken, "Empty application string")
return
}
GlobalScope.launch(Dispatchers.IO) {
Expand All @@ -61,7 +66,8 @@ class BroadcastReceiver : android.content.BroadcastReceiver() {
distributor.sendEndpoint(appId, connectorToken, endpoint)
} else {
Log.d(TAG, "Subscription with connectorToken $connectorToken exists for a different app. Refusing registration.")
distributor.sendRegistrationFailed(appId, connectorToken, "Connector token already exists")
// Internal_error: try again with a new token
distributor.sendRegistrationFailed(appId, connectorToken, FailedReason.INTERNAL_ERROR)
}
return@launch
}
Expand Down Expand Up @@ -99,7 +105,8 @@ class BroadcastReceiver : android.content.BroadcastReceiver() {
SubscriberServiceManager.refresh(app)
} catch (e: Exception) {
Log.w(TAG, "Failed to add subscription", e)
distributor.sendRegistrationFailed(appId, connectorToken, e.message)
// Try again when there is network
distributor.sendRegistrationFailed(appId, connectorToken, FailedReason.NETWORK)
}

// Add to log scrubber
Expand All @@ -109,6 +116,81 @@ class BroadcastReceiver : android.content.BroadcastReceiver() {
}
}

/**
* Get application package name
*/
private fun getApplication(context: Context, intent: Intent): String? {
return getApplicationAnd3(context, intent)
?: getApplicationAnd2(intent)
}

/**
* Get application package name following AND_3 specifications.
*/
private fun getApplicationAnd3(context: Context, intent: Intent): String? {
return if (Build.VERSION.SDK_INT >= 34) {
getApplicationAnd3SharedId(context, intent)
} else {
getApplicationAnd3PendingIntent(intent)
}
}

/**
* Try get application package name following AND_3 specifications for SDK>=34, with the shared
* identity.
*
* It fallback to [getApplicationAnd3PendingIntent] if the other application targets SDK<34.
*/
@RequiresApi(34)
private fun getApplicationAnd3SharedId(context: Context, intent: Intent): String? {
return sentFromPackage?.also {
// We got the package name with the shared identity
android.util.Log.d(TAG, "Registering $it. Package name retrieved with shared identity")
} ?: getApplicationAnd3PendingIntent(intent)?.let { packageId ->
// We got the package name with the pending intent, checking if that app targets SDK<34
return if (Build.VERSION.SDK_INT >= 33) {
context.packageManager.getApplicationInfo(
packageId,
PackageManager.ApplicationInfoFlags.of(
PackageManager.GET_META_DATA.toLong()
)
)
} else {
context.packageManager.getApplicationInfo(packageId, 0)
}.let { ai ->
if (ai.targetSdkVersion >= 34) {
android.util.Log.d(TAG, "App targeting Sdk >= 34 without shared identity, ignoring.")
null
} else {
packageId
}
}
}
}

/**
* Try get application package name following AND_3 specifications when running on
* a device with SDK<34 or receiving message from an application targeting SDK<34, with a pending
* intent.
*
* Always prefer [getApplicationAnd3SharedId] if possible.
*/
private fun getApplicationAnd3PendingIntent(intent: Intent): String? {
return intent.getParcelableExtra<PendingIntent>(EXTRA_PI)?.creatorPackage?.also {
android.util.Log.d(TAG, "Registering $it. Package name retrieved with PendingIntent")
}
}

/**
* Try get the application package name using AND_2 specifications
*/
@Deprecated("This follows AND_2 specifications. Will be removed.")
private fun getApplicationAnd2(intent: Intent): String? {
return intent.getStringExtra(EXTRA_APPLICATION)?.also {
android.util.Log.d(TAG, "Registering $it. Package name retrieved with legacy String extra")
}
}

private fun unregister(context: Context, intent: Intent) {
val connectorToken = intent.getStringExtra(EXTRA_TOKEN) ?: return
val app = context.applicationContext as Application
Expand Down
3 changes: 2 additions & 1 deletion app/src/main/java/io/heckel/ntfy/up/Constants.kt
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,8 @@ const val ACTION_UNREGISTER = "org.unifiedpush.android.distributor.UNREGISTER"
const val FEATURE_BYTES_MESSAGE = "org.unifiedpush.android.distributor.feature.BYTES_MESSAGE"

const val EXTRA_APPLICATION = "application"
const val EXTRA_PI = "pi"
const val EXTRA_TOKEN = "token"
const val EXTRA_ENDPOINT = "endpoint"
const val EXTRA_MESSAGE = "message"
const val EXTRA_FAILED_REASON = "reason"
const val EXTRA_BYTES_MESSAGE = "bytesMessage"
8 changes: 3 additions & 5 deletions app/src/main/java/io/heckel/ntfy/up/Distributor.kt
Original file line number Diff line number Diff line change
Expand Up @@ -11,11 +11,11 @@ import io.heckel.ntfy.util.Log
class Distributor(val context: Context) {
fun sendMessage(app: String, connectorToken: String, message: ByteArray) {
Log.d(TAG, "Sending MESSAGE to $app (token=$connectorToken): ${message.size} bytes")
RaiseAppToForegroundFactory.getInstance(context, app).raise()
val broadcastIntent = Intent()
broadcastIntent.`package` = app
broadcastIntent.action = ACTION_MESSAGE
broadcastIntent.putExtra(EXTRA_TOKEN, connectorToken)
broadcastIntent.putExtra(EXTRA_MESSAGE, String(message)) // UTF-8
broadcastIntent.putExtra(EXTRA_BYTES_MESSAGE, message)
context.sendBroadcast(broadcastIntent)
}
Expand All @@ -39,15 +39,13 @@ class Distributor(val context: Context) {
context.sendBroadcast(broadcastIntent)
}

fun sendRegistrationFailed(app: String, connectorToken: String, message: String?) {
fun sendRegistrationFailed(app: String, connectorToken: String, reason: FailedReason) {
Log.d(TAG, "Sending REGISTRATION_FAILED to $app (token=$connectorToken)")
val broadcastIntent = Intent()
broadcastIntent.`package` = app
broadcastIntent.action = ACTION_REGISTRATION_FAILED
broadcastIntent.putExtra(EXTRA_TOKEN, connectorToken)
if (message != null) {
broadcastIntent.putExtra(EXTRA_MESSAGE, message)
}
broadcastIntent.putExtra(EXTRA_FAILED_REASON, reason)
context.sendBroadcast(broadcastIntent)
}

Expand Down
23 changes: 23 additions & 0 deletions app/src/main/java/io/heckel/ntfy/up/FailedReason.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
package io.heckel.ntfy.up

/**
* A registration request may fail for different reasons.
*/
enum class FailedReason {
/**
* This is a generic error type, you can try to register again directly.
*/
INTERNAL_ERROR,
/**
* The registration failed because of missing network connection, try again when network is back.
*/
NETWORK,
/**
* The distributor requires a user action to work. For instance, the distributor may be log out of the push server and requires the user to log in. The user must interact with the distributor or sending a new registration will fail again.
*/
ACTION_REQUIRED,
/*
* The distributor requires a VAPID key and the app didn't provide one during registration.
VAPID_REQUIRED,
*/
}
27 changes: 27 additions & 0 deletions app/src/main/java/io/heckel/ntfy/up/LinkActivity.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
package io.heckel.ntfy.up

import android.app.Activity
import android.app.PendingIntent
import android.content.Intent
import android.os.Bundle
import android.util.Log

class LinkActivity: Activity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
intent?.data?.run {
Log.d(TAG, "Received request for $callingPackage")
val intent = Intent("org.unifiedpush.register.dummy_app")
val pendingIntent = PendingIntent.getBroadcast(this@LinkActivity, 0, intent, PendingIntent.FLAG_IMMUTABLE)
val result = Intent().apply {
putExtra(EXTRA_PI, pendingIntent)
}
setResult(RESULT_OK, result)
} ?: setResult(RESULT_CANCELED)
finish()
}

companion object {
private val TAG = LinkActivity::class.simpleName
}
}
Loading