diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml new file mode 100644 index 0000000..1fe438d --- /dev/null +++ b/.pre-commit-config.yaml @@ -0,0 +1,6 @@ +repos: + - repo: https://github.com/macisamuele/language-formatters-pre-commit-hooks + rev: v2.14.0 + hooks: + - id: pretty-format-java + args: [--autofix] diff --git a/android/app/src/main/java/com/thejeffcooper/hassmic/AutostartModule.java b/android/app/src/main/java/com/thejeffcooper/hassmic/AutostartModule.java index 299511a..4b47187 100644 --- a/android/app/src/main/java/com/thejeffcooper/hassmic/AutostartModule.java +++ b/android/app/src/main/java/com/thejeffcooper/hassmic/AutostartModule.java @@ -1,21 +1,10 @@ package com.thejeffcooper.hassmic; -import com.facebook.react.bridge.NativeModule; -import com.facebook.react.bridge.ReactApplicationContext; -import com.facebook.react.bridge.ReactContext; -import com.facebook.react.bridge.ReactContextBaseJavaModule; -import com.facebook.react.bridge.ReactMethod; - -import java.util.Map; -import java.util.HashMap; - import android.content.BroadcastReceiver; import android.content.Context; import android.content.Intent; -import android.content.IntentFilter; import android.os.Build; - public class AutostartModule extends BroadcastReceiver { @Override public void onReceive(Context context, Intent intent) { diff --git a/android/app/src/main/java/com/thejeffcooper/hassmic/BackgroundEventService.java b/android/app/src/main/java/com/thejeffcooper/hassmic/BackgroundEventService.java index 917c65c..b8cf3cf 100644 --- a/android/app/src/main/java/com/thejeffcooper/hassmic/BackgroundEventService.java +++ b/android/app/src/main/java/com/thejeffcooper/hassmic/BackgroundEventService.java @@ -2,26 +2,24 @@ import android.content.Intent; import android.os.Bundle; - import androidx.annotation.Nullable; - import com.facebook.react.HeadlessJsTaskService; import com.facebook.react.bridge.Arguments; import com.facebook.react.jstasks.HeadlessJsTaskConfig; public class BackgroundEventService extends HeadlessJsTaskService { - @Nullable - @Override - protected HeadlessJsTaskConfig getTaskConfig(Intent intent) { - Bundle extras = intent.getExtras(); + @Nullable + @Override + protected HeadlessJsTaskConfig getTaskConfig(Intent intent) { + Bundle extras = intent.getExtras(); - // Configure headless JS task - return new HeadlessJsTaskConfig( - "HassmicBackgroundTask", - extras != null ? Arguments.fromBundle(extras) : Arguments.createMap(), - 0, // timeout for the task - true // allow task in foreground + // Configure headless JS task + return new HeadlessJsTaskConfig( + "HassmicBackgroundTask", + extras != null ? Arguments.fromBundle(extras) : Arguments.createMap(), + 0, // timeout for the task + true // allow task in foreground ); - } + } } diff --git a/android/app/src/main/java/com/thejeffcooper/hassmic/BackgroundTaskModule.java b/android/app/src/main/java/com/thejeffcooper/hassmic/BackgroundTaskModule.java index de374ed..d34ee30 100644 --- a/android/app/src/main/java/com/thejeffcooper/hassmic/BackgroundTaskModule.java +++ b/android/app/src/main/java/com/thejeffcooper/hassmic/BackgroundTaskModule.java @@ -5,21 +5,13 @@ import android.content.Intent; import android.content.IntentFilter; import android.os.Build; -import android.os.Bundle; import android.util.Log; - import androidx.core.content.ContextCompat; - -import com.facebook.react.bridge.NativeModule; import com.facebook.react.bridge.ReactApplicationContext; -import com.facebook.react.bridge.ReactContext; import com.facebook.react.bridge.ReactContextBaseJavaModule; import com.facebook.react.bridge.ReactMethod; import com.facebook.react.modules.core.DeviceEventManagerModule; -import java.util.HashMap; -import java.util.Map; - public class BackgroundTaskModule extends ReactContextBaseJavaModule { private static ReactApplicationContext reactContext; @@ -27,26 +19,33 @@ public class BackgroundTaskModule extends ReactContextBaseJavaModule { public static final String KEY_FIRE_JS_EVENT = "HassMicFireJSEvent"; public static final String KEY_JS_EVENT_NAME = "HassMicJSEventName"; - BackgroundTaskModule(ReactApplicationContext context) { super(context); reactContext = context; - BroadcastReceiver jsEventRec = new BroadcastReceiver() { - @Override - public void onReceive(Context context, Intent intent) { - String eventName = intent.getStringExtra(KEY_JS_EVENT_NAME); - if (eventName == null || eventName.equals("")) { - Log.e("HassmicBackgroundTaskModule", "Was asked to send a JS event, but didn't get an event name"); - return; - } - Log.d("HassmicBackgroundTaskModule", "Sending event JS event " + eventName); - reactContext.getJSModule(DeviceEventManagerModule.RCTDeviceEventEmitter.class) - .emit(eventName, null); - } - }; - - ContextCompat.registerReceiver(reactContext, jsEventRec, new IntentFilter(KEY_FIRE_JS_EVENT), ContextCompat.RECEIVER_NOT_EXPORTED); + BroadcastReceiver jsEventRec = + new BroadcastReceiver() { + @Override + public void onReceive(Context context, Intent intent) { + String eventName = intent.getStringExtra(KEY_JS_EVENT_NAME); + if (eventName == null || eventName.equals("")) { + Log.e( + "HassmicBackgroundTaskModule", + "Was asked to send a JS event, but didn't get an event name"); + return; + } + Log.d("HassmicBackgroundTaskModule", "Sending event JS event " + eventName); + reactContext + .getJSModule(DeviceEventManagerModule.RCTDeviceEventEmitter.class) + .emit(eventName, null); + } + }; + + ContextCompat.registerReceiver( + reactContext, + jsEventRec, + new IntentFilter(KEY_FIRE_JS_EVENT), + ContextCompat.RECEIVER_NOT_EXPORTED); } @Override @@ -73,10 +72,12 @@ public void stopService() { } @ReactMethod - public void playSpeech(String url) { + public void playAudio(String url, boolean announce) { Log.d("HassmicBackgroundTaskModule", "Sending play media intent: " + url); - Intent playIntent = new Intent(BackgroundTaskService.PLAY_SPEECH_ACTION) - .putExtra(BackgroundTaskService.URL_KEY, url); + Intent playIntent = + new Intent(BackgroundTaskService.PLAY_AUDIO_ACTION) + .putExtra(BackgroundTaskService.URL_KEY, url) + .putExtra(BackgroundTaskService.ANNOUNCE_KEY, announce); Log.d("HassmicBackgroundTaskModule", playIntent.toString()); this.reactContext.sendBroadcast(playIntent); } diff --git a/android/app/src/main/java/com/thejeffcooper/hassmic/BackgroundTaskService.java b/android/app/src/main/java/com/thejeffcooper/hassmic/BackgroundTaskService.java index ebaf08c..e516feb 100644 --- a/android/app/src/main/java/com/thejeffcooper/hassmic/BackgroundTaskService.java +++ b/android/app/src/main/java/com/thejeffcooper/hassmic/BackgroundTaskService.java @@ -13,65 +13,93 @@ import android.content.Intent; import android.content.IntentFilter; import android.content.pm.ServiceInfo; -import android.media.AudioAttributes; -import android.media.MediaPlayer; -import android.net.Uri; import android.os.Build; -import android.os.Bundle; import android.os.Handler; import android.os.IBinder; -import android.os.PowerManager; import android.util.Log; - import androidx.annotation.RequiresApi; import androidx.core.app.NotificationCompat; import androidx.core.content.ContextCompat; - +import androidx.media3.common.AudioAttributes; +import androidx.media3.common.C; import androidx.media3.common.MediaItem; +import androidx.media3.common.Player; import androidx.media3.exoplayer.ExoPlayer; - import com.facebook.react.HeadlessJsTaskService; -import com.facebook.react.bridge.Arguments; -import com.facebook.react.bridge.WritableNativeMap; -import com.facebook.react.jstasks.HeadlessJsTaskConfig; -import com.facebook.react.modules.core.DeviceEventManagerModule; - -import java.io.IOException; -import java.util.HashSet; -import java.util.Set; -import javax.annotation.Nullable; public class BackgroundTaskService extends Service { - public static final String PLAY_SPEECH_ACTION = "com.thejeffcooper.hassmic.INTENT_PLAY_SOUND"; + public static final String PLAY_AUDIO_ACTION = "com.thejeffcooper.hassmic.INTENT_PLAY_AUDIO"; - public static final String EVENT_PLAY_SPEECH_START = "hassmic.SpeechStart"; - public static final String EVENT_PLAY_SPEECH_STOP = "hassmic.SpeechStop"; + public static final String EVENT_PLAY_SOUND_START = "hassmic.SpeechStart"; + public static final String EVENT_PLAY_SOUND_STOP = "hassmic.SpeechStop"; public static final String URL_KEY = "URL"; + public static final String ANNOUNCE_KEY = "ANNOUNCE"; private static final int SERVICE_NOTIFICATION_ID = 100001; private static final String CHANNEL_ID = "BACKGROUND_LISTEN"; private Handler handler = new Handler(); - private ExoPlayer exo; - - private Runnable runnableCode = new Runnable() { - @Override - public void run() { - - Context context = getApplicationContext(); - - // Start BackgroundEventService - Intent myIntent = new Intent(context, BackgroundEventService.class); - context.startService(myIntent); - - // Acquire wake lock - HeadlessJsTaskService.acquireWakeLockNow(context); - - // start the exoplayer - exo = new ExoPlayer.Builder(context).build(); - } - }; + private ExoPlayer audioExo; + private ExoPlayer announceExo; + + private Runnable runnableCode = + new Runnable() { + @Override + public void run() { + + Context context = getApplicationContext(); + + // Start BackgroundEventService + Intent myIntent = new Intent(context, BackgroundEventService.class); + context.startService(myIntent); + + // Acquire wake lock + HeadlessJsTaskService.acquireWakeLockNow(context); + + // Create the exoplayers: one for normal audio (music) and one for + // announcements. + + // Audio exoplayer + audioExo = + new ExoPlayer.Builder(context) + .setAudioAttributes( + new AudioAttributes.Builder() + .setContentType(C.AUDIO_CONTENT_TYPE_MUSIC) + .setUsage(C.USAGE_MEDIA) + .build(), + true) + .build(); + + // Announcement exoplayer + announceExo = + new ExoPlayer.Builder(context) + .setAudioAttributes( + new AudioAttributes.Builder() + .setContentType(C.AUDIO_CONTENT_TYPE_SPEECH) + .setUsage(C.USAGE_ASSISTANT) + .build(), + false) + .build(); + } + + // listen for important events and fire them back to JS + Player.Listener audioEventListener = + new Player.Listener() { + @Override + public void onEvents(Player p, Player.Events events) { + String which_player = null; + if (p == audioExo) { + which_player = "audio"; + } else if (p == announceExo) { + which_player = "announce"; + } + Log.d("HassmicBackgroundTaskService", "Got events for " + which_player); + + for (int i = 0; i < events.size(); i++) {} + } + }; + }; @Override public IBinder onBind(Intent intent) { @@ -81,31 +109,63 @@ public IBinder onBind(Intent intent) { @Override public void onCreate() { super.onCreate(); - IntentFilter filter = new IntentFilter(PLAY_SPEECH_ACTION); - ContextCompat.registerReceiver(getApplicationContext(), brec, filter, ContextCompat.RECEIVER_EXPORTED); + IntentFilter filter = new IntentFilter(PLAY_AUDIO_ACTION); + ContextCompat.registerReceiver( + getApplicationContext(), brec, filter, ContextCompat.RECEIVER_EXPORTED); Log.d("HassmicBackgroundTaskService", "Registered receiver for " + brec.toString()); } - private final BroadcastReceiver brec = new BroadcastReceiver() { - @Override - public void onReceive(Context context, Intent intent) { - String action = intent.getAction(); - if(action.equals(PLAY_SPEECH_ACTION)) { - String url = intent.getStringExtra(URL_KEY); - Log.d("HassmicBackgroundTaskService", "Playing " + url); - MediaItem i = MediaItem.fromUri(url); - exo.setMediaItem(i); - exo.prepare(); - exo.play(); - } - } - }; + private final BroadcastReceiver brec = + new BroadcastReceiver() { + @Override + public void onReceive(Context context, Intent intent) { + String action = intent.getAction(); + if (action.equals(PLAY_AUDIO_ACTION)) { + String url = intent.getStringExtra(URL_KEY); + boolean announce = intent.getBooleanExtra(ANNOUNCE_KEY, false); + Log.d( + "HassmicBackgroundTaskService", + "Playing " + url + " (announce = " + String.valueOf(announce) + ")"); + MediaItem i = MediaItem.fromUri(url); + + if (announce) { + // if we're announcing, pause the audioExo if it's playing + if (audioExo.isPlaying()) { + Log.d( + "HassmicBackgroundTaskService", "Playing announcement. Pausing playing audio"); + audioExo.pause(); + announceExo.addListener( + new Player.Listener() { + @Override + public void onPlaybackStateChanged(@Player.State int newState) { + if (newState == Player.STATE_ENDED) { + Log.d( + "HassmicBackgroundTaskService", + "Finished playing announcement. Resuming audio"); + audioExo.play(); + announceExo.removeListener(this); + } + } + }); + } + announceExo.setMediaItem(i); + announceExo.prepare(); + announceExo.play(); + } else { + audioExo.setMediaItem(i); + audioExo.prepare(); + audioExo.play(); + } + } + } + }; @Override public void onDestroy() { super.onDestroy(); Log.d("HassmicBackgroundTaskService", "Destroying service"); - exo.release(); + audioExo.release(); + announceExo.release(); this.handler.removeCallbacks(this.runnableCode); // Stop runnable execution getApplicationContext().unregisterReceiver(brec); stopForeground(STOP_FOREGROUND_REMOVE); @@ -118,20 +178,30 @@ public int onStartCommand(Intent intent, int flags, int startId) { // Create notification for foreground service Intent notificationIntent = new Intent(this, MainActivity.class); - PendingIntent contentIntent = PendingIntent.getActivity(this, 0, notificationIntent, PendingIntent.FLAG_CANCEL_CURRENT | PendingIntent.FLAG_IMMUTABLE); + PendingIntent contentIntent = + PendingIntent.getActivity( + this, + 0, + notificationIntent, + PendingIntent.FLAG_CANCEL_CURRENT | PendingIntent.FLAG_IMMUTABLE); if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { createChannel(); - Notification notification = new NotificationCompat.Builder(this, CHANNEL_ID) - .setContentIntent(contentIntent) - .setTicker("HassMic Active") // Informative ticker text - .setContentTitle("HassMic") // Your app's name - .setContentText("HassMic is Running") // Explain what's happening - .setSmallIcon(R.mipmap.ic_launcher) - .setOngoing(true) - .build(); + Notification notification = + new NotificationCompat.Builder(this, CHANNEL_ID) + .setContentIntent(contentIntent) + .setTicker("HassMic Active") // Informative ticker text + .setContentTitle("HassMic") // Your app's name + .setContentText("HassMic is Running") // Explain what's happening + .setSmallIcon(R.mipmap.ic_launcher) + .setOngoing(true) + .build(); if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { - startForeground(SERVICE_NOTIFICATION_ID, notification, ServiceInfo.FOREGROUND_SERVICE_TYPE_MICROPHONE | ServiceInfo.FOREGROUND_SERVICE_TYPE_MEDIA_PLAYBACK); + startForeground( + SERVICE_NOTIFICATION_ID, + notification, + ServiceInfo.FOREGROUND_SERVICE_TYPE_MICROPHONE + | ServiceInfo.FOREGROUND_SERVICE_TYPE_MEDIA_PLAYBACK); } else { startForeground(SERVICE_NOTIFICATION_ID, notification); } @@ -143,10 +213,11 @@ public int onStartCommand(Intent intent, int flags, int startId) { private void createChannel() { String description = "Background Notifications"; int importance = NotificationManager.IMPORTANCE_DEFAULT; - NotificationChannel channel = new NotificationChannel(CHANNEL_ID, "background_notifications", importance); + NotificationChannel channel = + new NotificationChannel(CHANNEL_ID, "background_notifications", importance); channel.setDescription(description); NotificationManager notificationManager = - (NotificationManager) getApplicationContext().getSystemService(NOTIFICATION_SERVICE); + (NotificationManager) getApplicationContext().getSystemService(NOTIFICATION_SERVICE); notificationManager.createNotificationChannel(channel); } diff --git a/android/app/src/main/java/com/thejeffcooper/hassmic/HassMicPackage.java b/android/app/src/main/java/com/thejeffcooper/hassmic/HassMicPackage.java index 53fd4f6..59d18a0 100644 --- a/android/app/src/main/java/com/thejeffcooper/hassmic/HassMicPackage.java +++ b/android/app/src/main/java/com/thejeffcooper/hassmic/HassMicPackage.java @@ -1,28 +1,26 @@ package com.thejeffcooper.hassmic; + import com.facebook.react.ReactPackage; import com.facebook.react.bridge.NativeModule; import com.facebook.react.bridge.ReactApplicationContext; import com.facebook.react.uimanager.ViewManager; - import java.util.ArrayList; import java.util.Collections; import java.util.List; public class HassMicPackage implements ReactPackage { - @Override - public List createViewManagers(ReactApplicationContext reactContext) { - return Collections.emptyList(); - } - - @Override - public List createNativeModules( - ReactApplicationContext reactContext) { - List modules = new ArrayList<>(); + @Override + public List createViewManagers(ReactApplicationContext reactContext) { + return Collections.emptyList(); + } - modules.add(new BackgroundTaskModule(reactContext)); + @Override + public List createNativeModules(ReactApplicationContext reactContext) { + List modules = new ArrayList<>(); - return modules; - } + modules.add(new BackgroundTaskModule(reactContext)); + return modules; + } } diff --git a/app/backgroundtask.ts b/app/backgroundtask.ts index fb1dab2..0bb4758 100644 --- a/app/backgroundtask.ts +++ b/app/backgroundtask.ts @@ -146,10 +146,9 @@ class BackgroundTaskManager_ { CheyenneSocket.sendMessage("tts-stop", {}); }); - CheyenneSocket.setPlaySpeechCallback((url) => { - console.log(`Playing tts: ${url}`); - this.playTTS(url); - //SoundPlayer.playUrl(url); + CheyenneSocket.setPlayAudioCallback((url: string, announce: boolean) => { + console.log(`Playing audio: '${url}' (announce=${announce})`); + this.playAudio(url, announce); }); CheyenneSocket.startServer(); console.log("Started server"); @@ -170,6 +169,8 @@ class BackgroundTaskManager_ { audioSource: 6, wavFile: "", // to make tsc happy; this isn't used anywhere }); + + // @ts-ignore: This error is some weird interaction between TS and Java LiveAudioStream.on("RNLiveAudioStream.data", (data) => { if (typeof data == "object") { console.warn(`Can't process: ${JSON.stringify(data)}`); @@ -219,9 +220,9 @@ class BackgroundTaskManager_ { BackgroundTaskModule.startService(); }; - // play some speech - playTTS = (url: string) => { - BackgroundTaskModule.playSpeech(url); + // play some audio + playAudio = (url: string, announce: boolean) => { + BackgroundTaskModule.playAudio(url, announce); }; } diff --git a/app/cheyenne.ts b/app/cheyenne.ts index b1d87fe..33e36d7 100644 --- a/app/cheyenne.ts +++ b/app/cheyenne.ts @@ -4,6 +4,7 @@ import { UUIDManager } from "./util"; import { APP_VERSION } from "./constants"; type CallbackType = ((s: T) => void) | null; +type PlayAudioCallbackType = ((url: string, announce: boolean) => void) | null; // "Cheyenne" protocol server class CheyenneServer { @@ -28,10 +29,10 @@ class CheyenneServer { }; // callback to play audio via URL - private _playSpeechCallback: CallbackType = null; + private _playAudioCallback: PlayAudioCallbackType = null; - setPlaySpeechCallback = (cb: CallbackType) => { - this._playSpeechCallback = cb; + setPlayAudioCallback = (cb: PlayAudioCallbackType) => { + this._playAudioCallback = cb; }; streamAudio = (streamData: Uint8Array) => { @@ -55,7 +56,7 @@ class CheyenneServer { } }; - sendMessage = (type: string, data: dict | undefined) => { + sendMessage = (type: string, data: object | undefined) => { if (this._sock) { this._sock.write( JSON.stringify({ @@ -115,30 +116,7 @@ class CheyenneServer { }); socket.on("data", (d) => { - console.info("Got data"); - try { - let m = JSON.parse(d.toString()); - - // TODO - move incoming message parsing somewhere else - switch (m["type"]) { - case "play-tts": - console.info("Got play-tts message"); - const data = m["data"] || {}; - const url = data["url"] || ""; - if (url) { - console.log(`Playing URL '${url}'`); - this._playSpeechCallback?.(url); - } else { - console.warn("message.data.url is not set"); - } - break; - - default: - console.warn(`Got unknown message type '${m["type"]}'`); - } - } catch (e) { - console.error(e); - } + this._handleIncomingData(d.toString()); }); console.info(`Got connection`); @@ -164,6 +142,46 @@ class CheyenneServer { await p; console.log("Server stopped"); }; + + private _handleIncomingData = async (d: string) => { + try { + let m = JSON.parse(d.toString()); + + switch (m["type"]) { + case "play-announce": + { + console.info("Got play-announce message"); + const data = m["data"] || {}; + const url = data["url"] || ""; + if (url) { + console.log(`Playing URL '${url}'`); + this._playAudioCallback?.(url, true); + } else { + console.warn("message.data.url is not set"); + } + } + break; + case "play-audio": + { + console.info("Got play-audio message"); + const data = m["data"] || {}; + const url = data["url"] || ""; + if (url) { + console.log(`Playing URL '${url}'`); + this._playAudioCallback?.(url, false); + } else { + console.warn("message.data.url is not set"); + } + } + break; + + default: + console.warn(`Got unknown message type '${m["type"]}'`); + } + } catch (e) { + console.error(e); + } + }; } export const CheyenneSocket: CheyenneServer = new CheyenneServer();