GithubHelp home page GithubHelp logo

android-connector's People

Contributors

connyduck avatar dependabot-preview[bot] avatar dependabot[bot] avatar nikclayton avatar p1gp1g avatar sparchatus avatar theonewiththebraid avatar

Stargazers

 avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar

Watchers

 avatar  avatar  avatar

android-connector's Issues

`onUnregistered` message is not always sent by `unregisterApp`

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:

  1. The user installs my app
  2. My app tells them to install a push distributor
  3. They decide to install ntfy
  4. After trying my app they decide they don't want to use it any more
  5. They uninstall ntfy
  6. They log out of my app, then uninstall it

Because they uninstalled ntfy before logging out of my app onUnregistered is not called, and the push subscription is never deleted from the server.

`registerAppWithDialog` doing too much

I think registerAppWithDialog might be doing too much.

At the moment it:

  1. Determines which distributor to use (or none, if none are available)
  2. Registers a specific instance with that distributor

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).

Registration dialogs don't follow platform styles

The registration dialogs don't follow platform styles.

It currently looks like this:

image

I'm about to send some PRs that fix this, and now it looks like this:

image

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

image

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).

`getDistributors` or similar new method should return friendly names

Like the application names registerAppWithDialog uses.

val distributorsArray = distributors.toTypedArray()
val distributorsNameArray = distributorsArray.map {
try {
val ai = context.packageManager.getApplicationInfo(it, 0)
context.packageManager.getApplicationLabel(ai)
} catch (e: PackageManager.NameNotFoundException) {
it
} as String
}.toTypedArray()
builder.setItems(distributorsNameArray) { _, which ->
val distributor = distributorsArray[which]
saveDistributor(context, distributor)
Log.d("UP-Registration", "saving: $distributor")
registerApp(context, instance)
}
val dialog: AlertDialog = builder.create()
dialog.show()

Applications force-killed by the manufacturer's Android

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 BroadcastReceivers 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.

Make dialog strings translateable

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.

RegisterAppWithDialog should have ignore option

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

Change API to be closer to the Android/D-Bus spec ?

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.

UnifiedPush preferences can get out of sync, registering silently fails

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).

  1. App calls UnifiedPush.registerAppWithDialog(context, "4").
  2. UnifiedPush.registerAppWithDialog calls UnifiedPush.registerApp(context, "4", features, messageForDistributor)
  3. 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".
  4. The intent is broadcast.
  5. MessagingReceiver.onReceive() receives the broadcast, and extracts the token
  6. MessagingReceiver.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.

Wakelock

Acquire and release wakelock before and after onMessage/onNewEndpoint

Library gets out of sync if distributor is uninstalled

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:

  1. Install ntfy
  2. Build and run the mainFlavorDebug variant of the sample app (so it doesn't include the FCM distributor)
  3. Click "Register" in the sample app
  4. Note the endpont that was returned when registering
  5. Uninstall ntfy
  6. Re-run the sample app

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.

  1. Re-install ntfy
  2. Re-run the sample app

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:

  1. Broadcast ACTION_REGISTRATION_FAILED if no distributors are available
  2. Broadcast a different action, and there was an associated new receiver callback to deal with the explicit case where no distributors are available

This would allow the end-user app to easily detect this case and respond to it.

CIRCULAR REFERENCE: com.android.tools.r8.kotlin.H

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]

Recommend Projects

  • React photo React

    A declarative, efficient, and flexible JavaScript library for building user interfaces.

  • Vue.js photo Vue.js

    🖖 Vue.js is a progressive, incrementally-adoptable JavaScript framework for building UI on the web.

  • Typescript photo Typescript

    TypeScript is a superset of JavaScript that compiles to clean JavaScript output.

  • TensorFlow photo TensorFlow

    An Open Source Machine Learning Framework for Everyone

  • Django photo Django

    The Web framework for perfectionists with deadlines.

  • D3 photo D3

    Bring data to life with SVG, Canvas and HTML. 📊📈🎉

Recommend Topics

  • javascript

    JavaScript (JS) is a lightweight interpreted programming language with first-class functions.

  • web

    Some thing interesting about web. New door for the world.

  • server

    A server is a program made to process requests and deliver data to clients.

  • Machine learning

    Machine learning is a way of modeling and interpreting data that allows a piece of software to respond intelligently.

  • Game

    Some thing interesting about game, make everyone happy.

Recommend Org

  • Facebook photo Facebook

    We are working to build community through open source technology. NB: members must have two-factor auth.

  • Microsoft photo Microsoft

    Open source projects and samples from Microsoft.

  • Google photo Google

    Google ❤️ Open Source for everyone.

  • D3 photo D3

    Data-Driven Documents codes.