unifiedpush / android-connector Goto Github PK
View Code? Open in Web Editor NEWMirror of https://codeberg.org/UnifiedPush/android-connector/
License: Apache License 2.0
Mirror of https://codeberg.org/UnifiedPush/android-connector/
License: Apache License 2.0
My expectation, from the docs, was that calling unregisterApp()
should always result in the receiver's onUnregistered
method being called.
It's not, and I think that could be a problem.
Suppose I use onUnregistered
to make an API call to my remote server to disable push messages. So something like:
@AndroidEntryPoint
class UnifiedPushBroadcastReceiver : MessagingReceiver() {
@Inject
lateinit var api: RemoteApi
// ...
override fun onUnregistered(context: Context, instance: String) {
api.unsubscribePush(instance) // HTTP DELETE /api/v1/push/:instance
}
}
Elsewhere in my code I allow the user to log out from their account, and as part of the log out logic I have this:
suspend fun logout(context: Context, account: Account) {
// ...
UnifiedPush.unregisterApp(context, account.unifiedPushInstance)
// ...
}
My expectation is the call to unregisterApp()
will send the broadcast message, onUnregistered
will be called, and an RPC to the server to unsubscribe from push messages will be sent.
However...
unregisterApp()
may return early, without sending the broadcast message. If I'm reading the code correctly this can happen if the user deletes the app they're using as the push distributor.
For example, suppose:
Because they uninstalled ntfy before logging out of my app onUnregistered
is not called, and the push subscription is never deleted from the server.
So it can be used by flutter-connector
This repository has moved to https://codeberg.org/UnifiedPush/android-connector/ and is a mirrored on github.
Please open pull requests/merge requests and issues on codeberg.
Could someone publish a new release on pub-dev? Thanks!
It is probably necessary to add the QUERY_ALL_PACKAGES
permission to AndroidManifest due to Android 11 restriction
I think registerAppWithDialog
might be doing too much.
At the moment it:
That can lead to some UX weirdness.
For example, if the app is trying to register multiple instances. The app does something like:
accounts.foreach {
UnifiedPush.registerAppWithDialog(context, it.unifiedPushInstance)
}
and if no distributors are installed that will cause the app to show the dialog N times, where N is the number of accounts the app is registering.
Since the default dialog text does not show the instance
value, to the user this can appear as though the "Ok" button on the dialog isn't working.
I suspect a better API might be to separate the distributor choice from the instance registration.
Something like this:
/**
* A distributor.
*
* @param pkgName Distributor's package name.
*/
// Constructor is internal so that only the UnifiedPush library can create
// these objects. Ensures client code can't accidentally create a bogus
// distributor and pass it in.
@JvmInline
value class Distributor internal constructor(private val pkgName: String) {
/**
* @return A label to use for this distributor in the UI; typically
* the distributor's application name.
*/
fun label(context: Context): String {
// ...
}
/** @return True if this distributor is installed on the device. */
fun isInstalled(context: Context): Boolean {
// …
}
}
/**
* Registers the app with one of the distributors installed on the
* device.
*
* If the app is already registered with a distributor, and the
* distributor is still installed on the device, it is returned.
*
* If no distributors are installed a warning dialog is shown to the
* user, and null is returned.
*
* If one distributor is installed it is selected.
*
* If multiple distributors are installed the user is shown a dialog
* to choose between them. The user's choice is used, unless they
* cancel the dialog, in which case no distributor is selected.
*
* @returns Null if no distributor is installed, or the user declined to
* choose one, otherwise the existing distributor (if already registered)
* or the one the user chose.
*/
fun registerAppWithDistributorWithDialog(context, ...): Distributor? {
return when (tryRegisterAppWithDistributor(context, ...)) {
NoInstalledDistributors -> {
// display warning dialog, and then...
null
}
Registered -> {
registerAppWithDistributor(it.distributor)
it.distributor
}
MultipleDistributors -> {
// display list, register on user's choice, and then...
registeredUsersChoiceOrNull
}
}
}
/** Return values from [tryRegisterAppWithDistributor] */
sealed interface TryRegisterWithDistributorResult {
/** No distributors are installed. End user app may want to warn user. */
data object NoInstalledDistributors : TryRegisterWithDistributorResult
/**
* Registration was successful
*
* @param distributor The registered distributor.
*/
data class Registered(val distributor: Distributor) : TryRegisterWithDistributorResult
/**
* Registration was not successful, as multiple distributors are installed.
*
* End user app may want to prompt user to choose one and call
* [registerAppWithDistributor] with the user's choice.
*
* @param distributors Available distributors
*/
data class MultipleDistributors(val distributors: List<Distributor>) : TryRegisterWithDistributorResult
}
/**
* Tries to register the app with a distributor on the device. If there are
* no distributors, or multiple distributors, the app is expected to handle
* this (e.g., by showing a warning, or a dialog allowing the user to choose)
* and then calling [registerAppWithDistributor] if appropriate.
*
* @return One of [TryRegisterWithDistributorResult].
*/
fun tryRegisterAppWithDistributor(context: Context, ...) : TryRegisterWithDistributorResult {
val distributors = getDistributors(context, features).map { Distributor(it) }
if (distributors.isEmpty) return NoInstalledDistributors
val ackDistributor = getAckDistributor(context)
if (ackDistributor != null && distributors.contains(ackDistributor)) {
return Registered(ackDistributor)
}
if (ackDistributor != null && !distributors.contains(ackDistributor)) {
// What to do here? Is this an error case, or should the library
// handle it?
return <something>
}
if (distributors.size == 1) {
saveDistributor(context, distributors.first())
return Registered(distributors.first())
}
return MultipleDistributors(distributors)
}
fun registerAppWithDistributor(context, distributor: Distributor) {
saveDistributor(context, distributor)
}
// ---
@JvmInline
value class Instance(private val s: String)
val DEFAULT_INSTANCE = Instance("default")
// Like registerApp, but the user has to provide the distributor. This ensures the
// app has to explicitly handle any errors when registering the app with a distributor
// instead of them potentially being silently swallowed by the library.
fun registerInstanceWithDistributor(
context: Context,
distributor: Distributor,
instance: Instance = DEFAULT_INSTANCE,
features: ArrayList<String> = DEFAULT_FEATURES,
messageForDistributor: String = "",
) {
// ...
}
Then the app client code can do this if they want the simple version.
registerAppWithDistributorWithDialog(context, …)?.let { distributor ->
accounts.foreach {
UnifiedPush.registerInstanceWithDistributor(context, distributor, it.unifiedPushInstance)
}
}
Or the client app can call tryRegisterAppWithDistributor
and handle the different failure cases themselves (e.g., they want to use a custom dialog, or show the options as a bottomsheet, or do nothing if no distributors are installed, etc).
The registration dialogs don't follow platform styles.
It currently looks like this:
I'm about to send some PRs that fix this, and now it looks like this:
They're stacked one of top of another for easy review.
There's a sixth PR that uses androidx.appcompat to get proper styling for the dialog. The result of that one is
However, that requires bumping the min SDK value, which you might not be in favour of.
I'll come back and update this issue with a link to the PRs in a minute.
PRs (in order, as I say, they're stacked, so each one includes the changes from the previous one).
The dialog to pick the distributor would be more understandable with application name instead of package_name.
Like the application names registerAppWithDialog
uses.
The current message distribution system relies on Android's BroadcastReceiver
and registering only the application package name. The causes problems if the target app (the one that should receive a notification from the UP implementor) has not been started, has been manually killed or has been reclaimed by the system. Then, there's no way, using BroadcastReceiver
s and Context.sendBroadcast()
, to bring an application back to life, which defeats a bit the pupose of UP.
The protocol should rely on a system that is able to start an app, whether is exists in memory or not. The only system I can think of, is creating a dedicating an empty activity for that, registering that activity (instead of the application package name) and start it whenever a UP implementor should distribute a notification, using Context.startActivity()
. This is how Android's sharing system works.
The docs say there are five methods to override in the receiver, there are only four.
https://github.com/UnifiedPush/documentation/compare/main...nikclayton:documentation-1:patch-1?expand=1 is the patch. I can't send this as a PR (or open an issue in the documentation repository) as the documentation repository has been limited to collaborators only.
If we send a registration request, and the distributor dies (does not respond within n seconds), pressing register with dialog again should allow picking a different distributor.
See: https://matrix.to/#/!vwmBiTqilorqNCbGab:matrix.org/$BdW3S_8WmDczVoTzNaXRXL6-47pWNnHuxb7Ktd81Kt0?via=libera.chat&via=t2bot.io&via=matrix.org
I can reproduce this too
Currently the text displayed to users in dialogs is hardcoded. It would be cool if they were Android string resources so client apps can override them and/or provide additional translations. Ideally the connector already comes with translations in common languages.
https://toot.martyn.berlin/@martyn/108520436299324159
[email protected] - @ Tusky One piece of feedback though is that the fallback should be "quieter". Every time I open tusky after not using it for a while I get a pop-up for "No distributor found". "I know that, I haven't made a decision yet, so please don't bug me" isn't an option ;-)
Idk how this could be achieved without the app having a setting to clear it, though
Also opening a flutter-connector issue since the concept applies to both
I am working on adding support for D-Bus as a Linux backend to the flutter connector.
I am running into troubles because the Android API exposed here hides the token behind an instance
chosen name instead, and store a mapping for that.
It complicated a lot of things when trying to abstract it, and I think staying closer to the specification and letting this to be handled by the user, or to some helpers instead, is perhaps not a bad idea.
What do you think ? The main problem is that we would break the API. I am however happy to propose something.
I don't have a reproduction recipe for this I'm afraid. I discovered this while I was experimenting with adding and removing distributors.
It's possible for the Unified Push preferences to get in to a state where there is an "instance" pref with a token (i.e., "$instance/$PREF_MASTER_TOKEN" exists), and the instance is not listed in the set PREF_MASTER_INSTANCE ("unifiedpush.instances").
This causes all attempts to register with that instance to silently fail.
While troubleshooting why some registrations weren't working I wrote this to log the UP preferences:
val prefs = context.getSharedPreferences(PREF_MASTER, Context.MODE_PRIVATE)
prefs.all.forEach {
Timber.w("UP pref: ${it.key} -> ${it.value}")
}
and that currently logs:
UP pref: unifiedpush.distributor -> io.heckel.ntfy
UP pref: unifiedpush.instances -> [3]
UP pref: 3/unifiedpush.connector -> a0590926-a962-4691-8479-0a0931b5ec52
UP pref: 4/unifiedpush.connector -> 846254b2-818d-40aa-9062-ff30d48f56af
UP pref: 5/unifiedpush.connector -> e7064b02-2ba3-4408-86e5-e7678f528b54
UP pref: 6/unifiedpush.connector -> d4e79db8-69af-4392-8d0d-3bc49cae9a14
Notice how I have 4 instances but only one of them is listed in PREF_MASTER_INSTANCE.
This then manifests as a failure to register the instance. The flow through the code looks like this (if the preferences are as above), trying to re-register instance "4" (assuming a provider is installed).
UnifiedPush.registerAppWithDialog(context, "4")
.UnifiedPush.registerAppWithDialog
calls UnifiedPush.registerApp(context, "4", features, messageForDistributor)
UnifiedPush.registerApp
calls Store.getTokenOrNew()
to get the token to include in the broadcast intent. For instance 4 this returns "846254b2-818d-40aa-9062-ff30d48f56af".MessagingReceiver.onReceive()
receives the broadcast, and extracts the tokenMessagingReceiver.onReceive()
then runs val instance = token?.let { store.tryGetInstance(it) } ?: return
And things go wrong.
a. There's no logging when this happens. Troubleshooting this problem would have been much easier if every early return in the Unified Push code had an associated debug or warning log message (in this case, a warning, since receiving a broadcast with an unexpected token is not part of the expected normal operation).
b. Store.tryGetInstance()
iterates over the instances in unifiedpush.instances
, and checks to see if the instance's token matches the provided token, and returns the instance if so.
This fails, because as already established, the instance is not listed in unifiedpush.instances
. getTokenOrNew()
could have checked this when it called at step 3.
The end result is the registration silently fails, because MessagingReceiver.onReceive()
returns early, does not log anything, and does not signal to the client code that something has gone wrong.
Acquire and release wakelock before and after onMessage/onNewEndpoint
The library can get out of sync if the push distributor is uninstalled.
This can be reproduced with the sample app. To do so, on a test device or emulator:
mainFlavorDebug
variant of the sample app (so it doesn't include the FCM distributor)Bug 1: You'll see that MainActivity
redirects to CheckActivity
, and CheckActivity
shows the old endpoint. While the endpoint may still exist (I don't know what ntfy does with registered endpoints when the app is uninstalled), no notifications from that endpoint are being delivered to the application.
Bug 2: Although ntfy has been re-installed, the old endpoint is still shown.
Bug 2 can be fixed in the sample app by re-writing onResume
from:
override fun onResume() {
super.onResume()
if (store.endpoint != null) {
goToCheckActivity(this)
finish()
} else {
internalReceiver = registerOnRegistrationUpdate {
if (store.endpoint != null) {
goToCheckActivity(this)
finish()
}
}
}
}
to:
override fun onResume() {
super.onResume()
internalReceiver = registerOnRegistrationUpdate {
if (store.endpoint != null) {
goToCheckActivity(this)
finish()
}
}
if (store.featureByteMessage) {
registerAppWithDialog(this, features = arrayListOf(FEATURE_BYTES_MESSAGE))
} else {
registerAppWithDialog(this, features = arrayListOf())
}
}
I'm not sure about bug 1. The cleanest thing I can think of is if registerAppWithDialog
either:
ACTION_REGISTRATION_FAILED
if no distributors are availableThis would allow the end-user app to easily detect this case and respond to it.
I tried adding the android-connector
library to the mastodon-android
gradle files as per https://unifiedpush.org/developers/android/
However, I am unable to build the project. I get CIRCULAR REFERENCE: com.android.tools.r8.kotlin.H
My changes are pretty simple so far. Changes can be found here: konradmoesch/mastodon-android@d21008d
error log:
> Task :mastodon:mergeExtDexDebug
AGPBI: {"kind":"error","text":"com.android.tools.r8.kotlin.H","sources":[{}],"tool":"D8"}
com.android.tools.r8.kotlin.H
AGPBI: {"kind":"error","text":"com.android.tools.r8.kotlin.H","sources":[{}],"tool":"D8"}
> Task :mastodon:mergeExtDexDebug FAILED
Execution failed for task ':mastodon:mergeExtDexDebug'.
> Could not resolve all files for configuration ':mastodon:debugRuntimeClasspath'.
> Failed to transform android-connector-2.2.0.aar (com.github.UnifiedPush:android-connector:2.2.0) to match attributes {artifactType=android-dex, asm-transformed-variant=NONE, dexing-enable-desugaring=true, dexing-enable-jacoco-instrumentation=false, dexing-is-debuggable=true, dexing-min-sdk=24, org.gradle.category=library, org.gradle.dependency.bundling=external, org.gradle.libraryelements=aar, org.gradle.status=release, org.gradle.usage=java-runtime}.
> Execution failed for DexingNoClasspathTransform: /home/km/.gradle/caches/transforms-3/5a9184e11a929640a46f8b4374be810e/transformed/android-connector-2.2.0-runtime.jar.
> Error while dexing.
> Failed to transform kotlin-stdlib-1.9.10.jar (org.jetbrains.kotlin:kotlin-stdlib:1.9.10) to match attributes {artifactType=android-dex, asm-transformed-variant=NONE, dexing-enable-desugaring=true, dexing-enable-jacoco-instrumentation=false, dexing-is-debuggable=true, dexing-min-sdk=24, org.gradle.category=library, org.gradle.libraryelements=jar, org.gradle.status=release, org.gradle.usage=java-runtime}.
> Execution failed for DexingNoClasspathTransform: /home/km/.gradle/caches/modules-2/files-2.1/org.jetbrains.kotlin/kotlin-stdlib/1.9.10/72812e8a368917ab5c0a5081b56915ffdfec93b7/kotlin-stdlib-1.9.10.jar.
> Error while dexing.
* Try:
> Run with --info or --debug option to get more log output.
> Run with --scan to get full insights.
* Exception is:
org.gradle.api.tasks.TaskExecutionException: Execution failed for task ':mastodon:mergeExtDexDebug'.
at org.gradle.api.internal.tasks.execution.CatchExceptionTaskExecuter.execute(CatchExceptionTaskExecuter.java:38)
[.....](shortened for the issue)
at org.gradle.internal.concurrent.ManagedExecutorImpl$1.run(ManagedExecutorImpl.java:48)
Cause 1: org.gradle.api.internal.artifacts.transform.TransformException: Failed to transform android-connector-2.2.0.aar (com.github.UnifiedPush:android-connector:2.2.0) to match attributes {artifactType=android-dex, asm-transformed-variant=NONE, dexing-enable-desugaring=true, dexing-enable-jacoco-instrumentation=false, dexing-is-debuggable=true, dexing-min-sdk=24, org.gradle.category=library, org.gradle.dependency.bundling=external, org.gradle.libraryelements=aar, org.gradle.status=release, org.gradle.usage=java-runtime}.
at org.gradle.api.internal.artifacts.transform.TransformingAsyncArtifactListener$TransformedArtifact.lambda$visit$2(TransformingAsyncArtifactListener.java:232)
[.....](shortened for the issue)
at org.gradle.internal.concurrent.ManagedExecutorImpl$1.run(ManagedExecutorImpl.java:48)
Caused by: org.gradle.api.internal.artifacts.transform.TransformException: Execution failed for DexingNoClasspathTransform: /home/km/.gradle/caches/transforms-3/5a9184e11a929640a46f8b4374be810e/transformed/android-connector-2.2.0-runtime.jar.
at org.gradle.api.internal.artifacts.transform.DefaultTransformerInvocationFactory$1.lambda$mapResult$3(DefaultTransformerInvocationFactory.java:159)
[.....](shortened for the issue)
at org.gradle.internal.execution.steps.IdentityCacheStep.lambda$executeDeferred$1(IdentityCacheStep.java:47)
... 25 more
Caused by: com.android.tools.r8.CompilationFailedException: Compilation failed to complete
at Version.fakeStackEntry(Version_4.0.52.java:0)
[.....](shortened for the issue)
... 115 more
Caused by: com.android.tools.r8.kotlin.H
at com.android.tools.r8.kotlin.j.a(R8_4.0.52_5a340ca2823c7e792fe09805c75f749b9d398d230bc0518bb54ae9b6b50addbe:42)
[.....](shortened for the issue)
at com.android.tools.r8.internal.vk.a(R8_4.0.52_5a340ca2823c7e792fe09805c75f749b9d398d230bc0518bb54ae9b6b50addbe:24)
... 118 more
Suppressed: java.lang.RuntimeException: java.util.concurrent.ExecutionException: com.android.tools.r8.kotlin.H
at com.android.tools.r8.D8.d(R8_4.0.52_5a340ca2823c7e792fe09805c75f749b9d398d230bc0518bb54ae9b6b50addbe:174)
[.....](shortened for the issue)
at java.base/java.lang.Thread.run(Thread.java:833)
Caused by: java.util.concurrent.ExecutionException: com.android.tools.r8.kotlin.H
at com.google.common.util.concurrent.AbstractFuture.getDoneValue(AbstractFuture.java:566)
[.....](shortened for the issue)
at com.android.tools.r8.D8.d(R8_4.0.52_5a340ca2823c7e792fe09805c75f749b9d398d230bc0518bb54ae9b6b50addbe:154)
... 125 more
Caused by: [CIRCULAR REFERENCE: com.android.tools.r8.kotlin.H]
Cause 2: org.gradle.api.internal.artifacts.transform.TransformException: Failed to transform kotlin-stdlib-1.9.10.jar (org.jetbrains.kotlin:kotlin-stdlib:1.9.10) to match attributes {artifactType=android-dex, asm-transformed-variant=NONE, dexing-enable-desugaring=true, dexing-enable-jacoco-instrumentation=false, dexing-is-debuggable=true, dexing-min-sdk=24, org.gradle.category=library, org.gradle.libraryelements=jar, org.gradle.status=release, org.gradle.usage=java-runtime}.
at org.gradle.api.internal.artifacts.transform.TransformingAsyncArtifactListener$TransformedArtifact.lambda$visit$2(TransformingAsyncArtifactListener.java:232)
[.....](shortened for the issue)
at java.base/java.lang.Thread.run(Thread.java:833)
Caused by: org.gradle.api.internal.artifacts.transform.TransformException: Execution failed for DexingNoClasspathTransform: /home/km/.gradle/caches/modules-2/files-2.1/org.jetbrains.kotlin/kotlin-stdlib/1.9.10/72812e8a368917ab5c0a5081b56915ffdfec93b7/kotlin-stdlib-1.9.10.jar.
at org.gradle.api.internal.artifacts.transform.DefaultTransformerInvocationFactory$1.lambda$mapResult$3(DefaultTransformerInvocationFactory.java:159)
[.....](shortened for the issue)
at org.gradle.internal.operations.DefaultBuildOperationQueue$WorkerRunnable.run(DefaultBuildOperationQueue.java:191)
... 5 more
Caused by: com.android.builder.dexing.DexArchiveBuilderException: Error while dexing.
at com.android.builder.dexing.D8DexArchiveBuilder.getExceptionToRethrow(D8DexArchiveBuilder.java:183)
[.....](shortened for the issue)
at org.gradle.internal.execution.steps.IdentityCacheStep.lambda$executeDeferred$1(IdentityCacheStep.java:47)
... 28 more
Caused by: com.android.tools.r8.CompilationFailedException: Compilation failed to complete
at Version.fakeStackEntry(Version_4.0.52.java:0)
[.....](shortened for the issue)
at com.android.builder.dexing.D8DexArchiveBuilder.convert(D8DexArchiveBuilder.java:120)
... 120 more
Caused by: com.android.tools.r8.kotlin.H
at com.android.tools.r8.kotlin.j.a(R8_4.0.52_5a340ca2823c7e792fe09805c75f749b9d398d230bc0518bb54ae9b6b50addbe:42)
[.....](shortened for the issue)
at com.android.tools.r8.internal.vk.a(R8_4.0.52_5a340ca2823c7e792fe09805c75f749b9d398d230bc0518bb54ae9b6b50addbe:24)
... 123 more
Suppressed: java.lang.RuntimeException: java.util.concurrent.ExecutionException: com.android.tools.r8.kotlin.H
at com.android.tools.r8.D8.d(R8_4.0.52_5a340ca2823c7e792fe09805c75f749b9d398d230bc0518bb54ae9b6b50addbe:174)
... 125 more
Caused by: java.util.concurrent.ExecutionException: com.android.tools.r8.kotlin.H
at com.google.common.util.concurrent.AbstractFuture.getDoneValue(AbstractFuture.java:566)
[.....](shortened for the issue)
at com.android.tools.r8.D8.d(R8_4.0.52_5a340ca2823c7e792fe09805c75f749b9d398d230bc0518bb54ae9b6b50addbe:154)
... 125 more
Caused by: [CIRCULAR REFERENCE: com.android.tools.r8.kotlin.H]
A declarative, efficient, and flexible JavaScript library for building user interfaces.
🖖 Vue.js is a progressive, incrementally-adoptable JavaScript framework for building UI on the web.
TypeScript is a superset of JavaScript that compiles to clean JavaScript output.
An Open Source Machine Learning Framework for Everyone
The Web framework for perfectionists with deadlines.
A PHP framework for web artisans
Bring data to life with SVG, Canvas and HTML. 📊📈🎉
JavaScript (JS) is a lightweight interpreted programming language with first-class functions.
Some thing interesting about web. New door for the world.
A server is a program made to process requests and deliver data to clients.
Machine learning is a way of modeling and interpreting data that allows a piece of software to respond intelligently.
Some thing interesting about visualization, use data art
Some thing interesting about game, make everyone happy.
We are working to build community through open source technology. NB: members must have two-factor auth.
Open source projects and samples from Microsoft.
Google ❤️ Open Source for everyone.
Alibaba Open Source for everyone
Data-Driven Documents codes.
China tencent open source team.