So I set out to make specialized voice assistant on Android. As I have strenuous relationship with Java to say the least, and I haven't yet had time to play with Kotlin, NativeScript seemed like the obvious choice.
Now this is a work in progress, but I already learned a lot about Android and I want to share some of my findings with you.
First for this task I need to constantly listen for the wake word and react accordingly. Obvious choice to implement this on any platform would be some sort of background service or daemon.
When googling nativescript and background services an excellent tutorial and an example repo come up on the top (I'm talking about this).
Alas, this is using IntentService which only runs on a schedule and exits once it's tasks are complete.
Creating a continuous background service though is pretty easy, there's just a lack of examples on this topic (which this article aims to fix).
The setup
You can find comeplete working example repo here.
For this article I'll assume that we're working with
typescript hello_world template:
tns create ServiceExample --ts --appid tk.ozymandias.ServiceExample
It shouldn't be difficult to adapt to other templates/technologies.
The service
First create a new subfolder under app/
, lets call it service
. This is purely to keep your project structure clean and tidy.
Now create a new file under app/service/continuous_service.android.ts
with these contents
export const CONTINUOUS_SERVICE_CLASSNAME = "tk.ozymandias.ServiceExample.Continuous_Service";
@JavaProxy("tk.ozymandias.ServiceExample.Continuous_Service")
class Continuous_Service extends android.app.Service {
private timerId: number;
onBind(): android.os.IBinder {
return null;
}
onCreate(): void {
super.onCreate();
console.log("SERVICE CREATED");
if (!this.timerId) {
this.timerId = setInterval(() => {
console.log("PING");
}, 1000)
}
}
onStartCommand(intent: android.content.Intent, flags: number, startId: number): number {
console.log("SERVICE STARTED");
return android.app.Service.START_REDELIVER_INTENT;
}
onDestroy(): void {
console.log("SERVICE DESTROYED");
super.onDestroy();
clearInterval(this.timerId);
}
}
Now this is a very basic service, it just runs in the background and prints "PING" to the console every second.
At the top we export service name as a constant, will be using this in a few places later.
Alas you need to specify the service name as a string literal in at least two more places.
First one is obvious here: the @JavaProxy
annotation.
Using a variable here will throw errors abouts existing extends and rather than the variable value it will be undefined.
Second will be in the manifest. More on that later.
onCreate
is called once when the service is instanciated, onStartCommand
is called everytime the service is started and onDestroy
is called when the service exits.
How the service is started and restarted depends on what
you return from onStartCommand
. You may be tempted to return START_STICKY
here, but that will cause crashes when your app is killed because the system will try to restart your service with null
intent.
Making it continuous
So far we have a functional service that starts with your app! But how do we keep it running when the app exits or is killed?
Let's start by making a broadcast receiver.
import { CONTINUOUS_SERVICE_CLASSNAME } from "./continuous-service.android";
export const RESTART_RECEIVER_CLASSNAME = "tk.ozymandias.ServiceExample.Restart_Receiver";
@JavaProxy("tk.ozymandias.ServiceExample.Restart_Receiver")
class Restart_Receiver extends android.content.BroadcastReceiver {
onReceive(context: android.content.Context, intent: android.content.Intent): void {
console.log("RESTART INTENT RECEIVED");
const serviceIntent = new android.content.Intent();
serviceIntent.setClassName(context, CONTINUOUS_SERVICE_CLASSNAME);
context.startService(serviceIntent);
}
}
Then let's modify our service a bit to invoke the broadcast receiver on exit so it can restart our service.
// At the top
import { RESTART_RECEIVER_CLASSNAME } from "./restart-receiver.android";
// In the onDestroy method in our service
onDestroy(): void {
// ...
const restartIntent = new android.content.Intent();
restartIntent.setClassName(this, RESTART_RECEIVER_CLASSNAME);
this.sendBroadcast(restartIntent);
}
You should also implement onTaskRemoved
method in our service.
It is called when user swipes away your app from the recents view.
In this situation (and probably others) onDestroy
isn't called by default.
So let's invoke onDestroy
by calling stopSelf
!
// ...
onTaskRemoved(intent: android.content.Intent): void {
console.log("TASK REMOVED");
this.stopSelf();
}
Now we have a continuously running service! When the app exits or is killed, we invoke our broadcast receiver,
which in turn restarts our service.
Unfortunately in newer versions of Android when system
kills your app because of low memory or due to battery optimizations, onDestroy
isn't guaranteed to be called.
Foreground Service
Fortunately there's an official way to work around that.
What we need is to make our service a Foreground Service.
The downside is that we must present a persistent notification, however starting with Oreo this notification can be hidden from the system settings
without impacting our service.
We need to modify our service yet again, this time the
onCreate
method:
// In the onCreate method in our service
onCreate(): void {
// ...
const builder: android.app.Notification.Builder = new android.app.Notification.Builder(app.android.context);
// Need to check api level, NotificationChannel is required but only available on Oreo and above
if (android.os.Build.VERSION.SDK_INT >= 26) {
const channel: android.app.NotificationChannel = new android.app.NotificationChannel(
"persistence", "Service running indicator", android.app.NotificationManager.IMPORTANCE_LOW
);
const manager: android.app.NotificationManager = (<android.app.Activity>app.android.context).getSystemService(android.content.Context.NOTIFICATION_SERVICE);
channel.enableLights(false);
channel.enableVibration(false);
manager.createNotificationChannel(channel);
builder.setChannelId("persistence");
}
const notification: android.app.Notification = builder.build();
this.startForeground(13, notification);
}
And this makes a continuous foreground service with a
persistent notification that will keep running pretty
much no matter what (it can still be forced stopped from the settings).
Finishing touches
Now if you try the code so far it will crash.
That's because we haven't declared anything in the
AndroidManifest.xml
!
What we need to declare is the permissions we need (only on latest versions of Android), the service and the receiver.
Without further ado, here's the manifest:
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
package="__PACKAGE__"
android:versionCode="1"
android:versionName="1.0">
<supports-screens
android:smallScreens="true"
android:normalScreens="true"
android:largeScreens="true"
android:xlargeScreens="true"/>
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE"/>
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE"/>
<uses-permission android:name="android.permission.INTERNET"/>
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<application
android:name="com.tns.NativeScriptApplication"
android:allowBackup="true"
android:icon="@drawable/icon"
android:label="@string/app_name"
android:theme="@style/AppTheme">
<activity
android:name="com.tns.NativeScriptActivity"
android:label="@string/title_activity_kimera"
android:configChanges="keyboard|keyboardHidden|orientation|screenSize|smallestScreenSize|screenLayout|locale|uiMode"
android:theme="@style/LaunchScreenTheme">
<meta-data android:name="SET_THEME_ON_LAUNCH" android:resource="@style/AppTheme" />
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
<activity android:name="com.tns.ErrorReportActivity"/>
<service android:enabled="true" android:name="tk.ozymandias.ServiceExample.Continuous_Service" />
<receiver
android:name="tk.ozymandias.ServiceExample.Restart_Receiver"
android:enabled="true"
android:exported="true"
android:label="ContinuousServiceRestarter" />
</application>
</manifest>
Extra Bonus
You may have noticed that the notification we get is
generic "app is running" notification that goes to settings when tapped.
We can do better!
// In the onCreate method in our service
onCreate(): void {
// ...
const appIntent: android.content.Intent = new android.content.Intent(app.android.context, com.tns.NativeScriptActivity.class);
const pendingIntent: android.app.PendingIntent = android.app.PendingIntent.getActivity(app.android.context, 0, appIntent, 0);
const builder: android.app.Notification.Builder = new android.app.Notification.Builder(app.android.context);
builder
.setContentText("Custom notification, F'Yeah!")
.setSmallIcon(android.R.drawable.btn_star_big_on)
.setContentIntent(pendingIntent);
// ...
}
You may need to declare const com: any;
somewhere
near the top of the file or typescript might throw a fit.
So what have we done here?
We created a pending intent pointing to the main activity of our app, so now when notification is tapped
it will open your app.
As for notification options, the important bits are
setContentText
and setSmallIcon
. If both of these aren't present at the minimum, you'll still get a generic
notification.
That's all folks
This has been my first article, please be gentle.
Top comments (6)
Makes a lot of sense! I’m less familiar with android than I am ios. If you wanted to have a service like this only run while the app is in the background (ok to stop when app killed), what do I focus on to make this happen? Thanks!!
Just skip the whole "Making it continuous" section. Services behave like that by default.
getting the error:
Error: com.tns.system.classes.loading.LookedUpClassNotFound: Class "tk.ozymandias.ServiceExample.Restart_Receiver" not found.
I'm sorry, I'm not getting notifications for comments.
There are 2 possibilities for this error. First, you should make sure
importHelpers
is false in tsconfig.json. Next, and this only applies to NativeScript 7, you must put@NativeClass()
decorator above all other class decorators.You probably need to do a clean build to fix this issue.
Very well explained. Kudos.