/*
 * Copyright (C) 2014 The Android Open Source Project
 *
 * Licensed under the Apache License, Version 2.0 (the "License");
 * you may not use this file except in compliance with the License.
 * You may obtain a copy of the License at
 *
 *      http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS,
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 * See the License for the specific language governing permissions and
 * limitations under the License.
 */

package com.android.test.soundtrigger;

import android.Manifest;
import android.app.Service;
import android.content.BroadcastReceiver;
import android.content.Context;
import android.content.Intent;
import android.content.IntentFilter;
import android.content.pm.PackageManager;
import android.hardware.soundtrigger.SoundTrigger.GenericSoundModel;
import android.media.AudioAttributes;
import android.media.AudioFormat;
import android.media.AudioManager;
import android.media.AudioRecord;
import android.media.AudioTrack;
import android.media.MediaPlayer;
import android.media.soundtrigger.SoundTriggerDetector;
import android.net.Uri;
import android.os.Binder;
import android.os.IBinder;
import android.util.Log;

import java.io.File;
import java.io.FileInputStream;
import java.io.FileOutputStream;
import java.io.IOException;
import java.util.HashMap;
import java.util.Map;
import java.util.Properties;
import java.util.Random;
import java.util.UUID;

public class SoundTriggerTestService extends Service {
    private static final String TAG = "SoundTriggerTestSrv";
    private static final String INTENT_ACTION = "com.android.intent.action.MANAGE_SOUND_TRIGGER";

    // Binder given to clients.
    private final IBinder mBinder;
    private final Map<UUID, ModelInfo> mModelInfoMap;
    private SoundTriggerUtil mSoundTriggerUtil;
    private Random mRandom;
    private UserActivity mUserActivity;

    public interface UserActivity {
        void addModel(UUID modelUuid, String state);
        void setModelState(UUID modelUuid, String state);
        void showMessage(String msg, boolean showToast);
        void handleDetection(UUID modelUuid);
    }

    public SoundTriggerTestService() {
        super();
        mRandom = new Random();
        mModelInfoMap = new HashMap();
        mBinder = new SoundTriggerTestBinder();
    }

    @Override
    public synchronized int onStartCommand(Intent intent, int flags, int startId) {
        if (mModelInfoMap.isEmpty()) {
            mSoundTriggerUtil = new SoundTriggerUtil(this);
            loadModelsInDataDir();
        }

        // If we get killed, after returning from here, restart
        return START_STICKY;
    }

    @Override
    public void onCreate() {
        super.onCreate();
        IntentFilter filter = new IntentFilter();
        filter.addAction(INTENT_ACTION);
        registerReceiver(mBroadcastReceiver, filter);

        // Make sure the data directory exists, and we're the owner of it.
        try {
            getFilesDir().mkdir();
        } catch (Exception e) {
            // Don't care - we either made it, or it already exists.
        }
    }

    @Override
    public void onDestroy() {
        super.onDestroy();
        stopAllRecognitionsAndUnload();
        unregisterReceiver(mBroadcastReceiver);
    }

    private final BroadcastReceiver mBroadcastReceiver = new BroadcastReceiver() {
        @Override
        public void onReceive(Context context, Intent intent) {
            if (intent != null && INTENT_ACTION.equals(intent.getAction())) {
                String command = intent.getStringExtra("command");
                if (command == null) {
                    Log.e(TAG, "No 'command' specified in " + INTENT_ACTION);
                } else {
                    try {
                        if (command.equals("load")) {
                            loadModel(getModelUuidFromIntent(intent));
                        } else if (command.equals("unload")) {
                            unloadModel(getModelUuidFromIntent(intent));
                        } else if (command.equals("start")) {
                            startRecognition(getModelUuidFromIntent(intent));
                        } else if (command.equals("stop")) {
                            stopRecognition(getModelUuidFromIntent(intent));
                        } else if (command.equals("play_trigger")) {
                            playTriggerAudio(getModelUuidFromIntent(intent));
                        } else if (command.equals("play_captured")) {
                            playCapturedAudio(getModelUuidFromIntent(intent));
                        } else if (command.equals("set_capture")) {
                            setCaptureAudio(getModelUuidFromIntent(intent),
                                    intent.getBooleanExtra("enabled", true));
                        } else if (command.equals("set_capture_timeout")) {
                            setCaptureAudioTimeout(getModelUuidFromIntent(intent),
                                    intent.getIntExtra("timeout", 5000));
                        } else {
                            Log.e(TAG, "Unknown command '" + command + "'");
                        }
                    } catch (Exception e) {
                        Log.e(TAG, "Failed to process " + command, e);
                    }
                }
            }
        }
    };

    private UUID getModelUuidFromIntent(Intent intent) {
        // First, see if the specified the UUID straight up.
        String value = intent.getStringExtra("modelUuid");
        if (value != null) {
            return UUID.fromString(value);
        }

        // If they specified a name, use that to iterate through the map of models and find it.
        value = intent.getStringExtra("name");
        if (value != null) {
            for (ModelInfo modelInfo : mModelInfoMap.values()) {
                if (value.equals(modelInfo.name)) {
                    return modelInfo.modelUuid;
                }
            }
            Log.e(TAG, "Failed to find a matching model with name '" + value + "'");
        }

        // We couldn't figure out what they were asking for.
        throw new RuntimeException("Failed to get model from intent - specify either " +
                "'modelUuid' or 'name'");
    }

    /**
     * Will be called when the service is killed (through swipe aways, not if we're force killed).
     */
    @Override
    public void onTaskRemoved(Intent rootIntent) {
        super.onTaskRemoved(rootIntent);
        stopAllRecognitionsAndUnload();
        stopSelf();
    }

    @Override
    public synchronized IBinder onBind(Intent intent) {
        return mBinder;
    }

    public class SoundTriggerTestBinder extends Binder {
        SoundTriggerTestService getService() {
            // Return instance of our parent so clients can call public methods.
            return SoundTriggerTestService.this;
        }
    }

    public synchronized void setUserActivity(UserActivity activity) {
        mUserActivity = activity;
        if (mUserActivity != null) {
            for (Map.Entry<UUID, ModelInfo> entry : mModelInfoMap.entrySet()) {
                mUserActivity.addModel(entry.getKey(), entry.getValue().name);
                mUserActivity.setModelState(entry.getKey(), entry.getValue().state);
            }
        }
    }

    private synchronized void stopAllRecognitionsAndUnload() {
        Log.e(TAG, "Stop all recognitions");
        for (ModelInfo modelInfo : mModelInfoMap.values()) {
            Log.e(TAG, "Loop " + modelInfo.modelUuid);
            if (modelInfo.detector != null) {
                Log.i(TAG, "Stopping recognition for " + modelInfo.name);
                try {
                    modelInfo.detector.stopRecognition();
                } catch (Exception e) {
                    Log.e(TAG, "Failed to stop recognition", e);
                }
                try {
                    mSoundTriggerUtil.deleteSoundModel(modelInfo.modelUuid);
                    modelInfo.detector = null;
                } catch (Exception e) {
                    Log.e(TAG, "Failed to unload sound model", e);
                }
            }
        }
    }

    // Helper struct for holding information about a model.
    public static class ModelInfo {
        public String name;
        public String state;
        public UUID modelUuid;
        public UUID vendorUuid;
        public MediaPlayer triggerAudioPlayer;
        public SoundTriggerDetector detector;
        public byte modelData[];
        public boolean captureAudio;
        public int captureAudioMs;
        public AudioTrack captureAudioTrack;
    }

    private GenericSoundModel createNewSoundModel(ModelInfo modelInfo) {
        return new GenericSoundModel(modelInfo.modelUuid, modelInfo.vendorUuid,
                modelInfo.modelData);
    }

    public synchronized void loadModel(UUID modelUuid) {
        ModelInfo modelInfo = mModelInfoMap.get(modelUuid);
        if (modelInfo == null) {
            postError("Could not find model for: " + modelUuid.toString());
            return;
        }

        postMessage("Loading model: " + modelInfo.name);

        GenericSoundModel soundModel = createNewSoundModel(modelInfo);

        boolean status = mSoundTriggerUtil.addOrUpdateSoundModel(soundModel);
        if (status) {
            postToast("Successfully loaded " + modelInfo.name + ", UUID=" + soundModel.uuid);
            setModelState(modelInfo, "Loaded");
        } else {
            postErrorToast("Failed to load " + modelInfo.name + ", UUID=" + soundModel.uuid + "!");
            setModelState(modelInfo, "Failed to load");
        }
    }

    public synchronized void unloadModel(UUID modelUuid) {
        ModelInfo modelInfo = mModelInfoMap.get(modelUuid);
        if (modelInfo == null) {
            postError("Could not find model for: " + modelUuid.toString());
            return;
        }

        postMessage("Unloading model: " + modelInfo.name);

        GenericSoundModel soundModel = mSoundTriggerUtil.getSoundModel(modelUuid);
        if (soundModel == null) {
            postErrorToast("Sound model not found for " + modelInfo.name + "!");
            return;
        }
        modelInfo.detector = null;
        boolean status = mSoundTriggerUtil.deleteSoundModel(modelUuid);
        if (status) {
            postToast("Successfully unloaded " + modelInfo.name + ", UUID=" + soundModel.uuid);
            setModelState(modelInfo, "Unloaded");
        } else {
            postErrorToast("Failed to unload " +
                    modelInfo.name + ", UUID=" + soundModel.uuid + "!");
            setModelState(modelInfo, "Failed to unload");
        }
    }

    public synchronized void reloadModel(UUID modelUuid) {
        ModelInfo modelInfo = mModelInfoMap.get(modelUuid);
        if (modelInfo == null) {
            postError("Could not find model for: " + modelUuid.toString());
            return;
        }
        postMessage("Reloading model: " + modelInfo.name);
        GenericSoundModel soundModel = mSoundTriggerUtil.getSoundModel(modelUuid);
        if (soundModel == null) {
            postErrorToast("Sound model not found for " + modelInfo.name + "!");
            return;
        }
        GenericSoundModel updated = createNewSoundModel(modelInfo);
        boolean status = mSoundTriggerUtil.addOrUpdateSoundModel(updated);
        if (status) {
            postToast("Successfully reloaded " + modelInfo.name + ", UUID=" + modelInfo.modelUuid);
            setModelState(modelInfo, "Reloaded");
        } else {
            postErrorToast("Failed to reload "
                    + modelInfo.name + ", UUID=" + modelInfo.modelUuid + "!");
            setModelState(modelInfo, "Failed to reload");
        }
    }

    public synchronized void startRecognition(UUID modelUuid) {
        ModelInfo modelInfo = mModelInfoMap.get(modelUuid);
        if (modelInfo == null) {
            postError("Could not find model for: " + modelUuid.toString());
            return;
        }

        if (modelInfo.detector == null) {
            postMessage("Creating SoundTriggerDetector for " + modelInfo.name);
            modelInfo.detector = mSoundTriggerUtil.createSoundTriggerDetector(
                    modelUuid, new DetectorCallback(modelInfo));
        }

        postMessage("Starting recognition for " + modelInfo.name + ", UUID=" + modelInfo.modelUuid);
        if (modelInfo.detector.startRecognition(modelInfo.captureAudio ?
                SoundTriggerDetector.RECOGNITION_FLAG_CAPTURE_TRIGGER_AUDIO :
                SoundTriggerDetector.RECOGNITION_FLAG_ALLOW_MULTIPLE_TRIGGERS)) {
            setModelState(modelInfo, "Started");
        } else {
            postErrorToast("Fast failure attempting to start recognition for " +
                    modelInfo.name + ", UUID=" + modelInfo.modelUuid);
            setModelState(modelInfo, "Failed to start");
        }
    }

    public synchronized void stopRecognition(UUID modelUuid) {
        ModelInfo modelInfo = mModelInfoMap.get(modelUuid);
        if (modelInfo == null) {
            postError("Could not find model for: " + modelUuid.toString());
            return;
        }

        if (modelInfo.detector == null) {
            postErrorToast("Stop called on null detector for " +
                    modelInfo.name + ", UUID=" + modelInfo.modelUuid);
            return;
        }
        postMessage("Triggering stop recognition for " +
                modelInfo.name + ", UUID=" + modelInfo.modelUuid);
        if (modelInfo.detector.stopRecognition()) {
            setModelState(modelInfo, "Stopped");
        } else {
            postErrorToast("Fast failure attempting to stop recognition for " +
                    modelInfo.name + ", UUID=" + modelInfo.modelUuid);
            setModelState(modelInfo, "Failed to stop");
        }
    }

    public synchronized void playTriggerAudio(UUID modelUuid) {
        ModelInfo modelInfo = mModelInfoMap.get(modelUuid);
        if (modelInfo == null) {
            postError("Could not find model for: " + modelUuid.toString());
            return;
        }
        if (modelInfo.triggerAudioPlayer != null) {
            postMessage("Playing trigger audio for " + modelInfo.name);
            modelInfo.triggerAudioPlayer.start();
        } else {
            postMessage("No trigger audio for " + modelInfo.name);
        }
    }

    public synchronized void playCapturedAudio(UUID modelUuid) {
        ModelInfo modelInfo = mModelInfoMap.get(modelUuid);
        if (modelInfo == null) {
            postError("Could not find model for: " + modelUuid.toString());
            return;
        }
        if (modelInfo.captureAudioTrack != null) {
            postMessage("Playing captured audio for " + modelInfo.name);
            modelInfo.captureAudioTrack.stop();
            modelInfo.captureAudioTrack.reloadStaticData();
            modelInfo.captureAudioTrack.play();
        } else {
            postMessage("No captured audio for " + modelInfo.name);
        }
    }

    public synchronized void setCaptureAudioTimeout(UUID modelUuid, int captureTimeoutMs) {
        ModelInfo modelInfo = mModelInfoMap.get(modelUuid);
        if (modelInfo == null) {
            postError("Could not find model for: " + modelUuid.toString());
            return;
        }
        modelInfo.captureAudioMs = captureTimeoutMs;
        Log.i(TAG, "Set " + modelInfo.name + " capture audio timeout to " +
                captureTimeoutMs + "ms");
    }

    public synchronized void setCaptureAudio(UUID modelUuid, boolean captureAudio) {
        ModelInfo modelInfo = mModelInfoMap.get(modelUuid);
        if (modelInfo == null) {
            postError("Could not find model for: " + modelUuid.toString());
            return;
        }
        modelInfo.captureAudio = captureAudio;
        Log.i(TAG, "Set " + modelInfo.name + " capture audio to " + captureAudio);
    }

    public synchronized boolean hasMicrophonePermission() {
        return getBaseContext().checkSelfPermission(Manifest.permission.RECORD_AUDIO)
                == PackageManager.PERMISSION_GRANTED;
    }

    public synchronized boolean modelHasTriggerAudio(UUID modelUuid) {
        ModelInfo modelInfo = mModelInfoMap.get(modelUuid);
        return modelInfo != null && modelInfo.triggerAudioPlayer != null;
    }

    public synchronized boolean modelWillCaptureTriggerAudio(UUID modelUuid) {
        ModelInfo modelInfo = mModelInfoMap.get(modelUuid);
        return modelInfo != null && modelInfo.captureAudio;
    }

    public synchronized boolean modelHasCapturedAudio(UUID modelUuid) {
        ModelInfo modelInfo = mModelInfoMap.get(modelUuid);
        return modelInfo != null && modelInfo.captureAudioTrack != null;
    }

    private void loadModelsInDataDir() {
        // Load all the models in the data dir.
        boolean loadedModel = false;
        for (File file : getFilesDir().listFiles()) {
            // Find meta-data in .properties files, ignore everything else.
            if (!file.getName().endsWith(".properties")) {
                continue;
            }

            try (FileInputStream in = new FileInputStream(file)) {
                Properties properties = new Properties();
                properties.load(in);
                createModelInfo(properties);
                loadedModel = true;
            } catch (Exception e) {
                Log.e(TAG, "Failed to load properties file " + file.getName());
            }
        }

        // Create a few dummy models if we didn't load anything.
        if (!loadedModel) {
            Properties dummyModelProperties = new Properties();
            for (String name : new String[]{"1", "2", "3"}) {
                dummyModelProperties.setProperty("name", "Model " + name);
                createModelInfo(dummyModelProperties);
            }
        }
    }

    /** Parses a Properties collection to generate a sound model.
     *
     * Missing keys are filled in with default/random values.
     * @param properties Has the required 'name' property, but the remaining 'modelUuid',
     *                   'vendorUuid', 'triggerAudio', and 'dataFile' optional properties.
     *
     */
    private synchronized void createModelInfo(Properties properties) {
        try {
            ModelInfo modelInfo = new ModelInfo();

            if (!properties.containsKey("name")) {
                throw new RuntimeException("must have a 'name' property");
            }
            modelInfo.name = properties.getProperty("name");

            if (properties.containsKey("modelUuid")) {
                modelInfo.modelUuid = UUID.fromString(properties.getProperty("modelUuid"));
            } else {
                modelInfo.modelUuid = UUID.randomUUID();
            }

            if (properties.containsKey("vendorUuid")) {
                modelInfo.vendorUuid = UUID.fromString(properties.getProperty("vendorUuid"));
            } else {
                modelInfo.vendorUuid = UUID.randomUUID();
            }

            if (properties.containsKey("triggerAudio")) {
                modelInfo.triggerAudioPlayer = MediaPlayer.create(this, Uri.parse(
                        getFilesDir().getPath() + "/" + properties.getProperty("triggerAudio")));
                if (modelInfo.triggerAudioPlayer.getDuration() == 0) {
                    modelInfo.triggerAudioPlayer.release();
                    modelInfo.triggerAudioPlayer = null;
                }
            }

            if (properties.containsKey("dataFile")) {
                File modelDataFile = new File(
                        getFilesDir().getPath() + "/" + properties.getProperty("dataFile"));
                modelInfo.modelData = new byte[(int) modelDataFile.length()];
                FileInputStream input = new FileInputStream(modelDataFile);
                input.read(modelInfo.modelData, 0, modelInfo.modelData.length);
            } else {
                modelInfo.modelData = new byte[1024];
                mRandom.nextBytes(modelInfo.modelData);
            }

            modelInfo.captureAudioMs = Integer.parseInt((String) properties.getOrDefault(
                    "captureAudioDurationMs", "5000"));

            // TODO: Add property support for keyphrase models when they're exposed by the
            // service.

            // Update our maps containing the button -> id and id -> modelInfo.
            mModelInfoMap.put(modelInfo.modelUuid, modelInfo);
            if (mUserActivity != null) {
                mUserActivity.addModel(modelInfo.modelUuid, modelInfo.name);
                mUserActivity.setModelState(modelInfo.modelUuid, modelInfo.state);
            }
        } catch (IOException e) {
            Log.e(TAG, "Error parsing properties for " + properties.getProperty("name"), e);
        }
    }

    private class CaptureAudioRecorder implements Runnable {
        private final ModelInfo mModelInfo;
        private final SoundTriggerDetector.EventPayload mEvent;

        public CaptureAudioRecorder(ModelInfo modelInfo, SoundTriggerDetector.EventPayload event) {
            mModelInfo = modelInfo;
            mEvent = event;
        }

        @Override
        public void run() {
            AudioFormat format = mEvent.getCaptureAudioFormat();
            if (format == null) {
                postErrorToast("No audio format in recognition event.");
                return;
            }

            AudioRecord audioRecord = null;
            AudioTrack playbackTrack = null;
            try {
                // Inform the audio flinger that we really do want the stream from the soundtrigger.
                AudioAttributes.Builder attributesBuilder = new AudioAttributes.Builder();
                attributesBuilder.setInternalCapturePreset(1999);
                AudioAttributes attributes = attributesBuilder.build();

                // Make sure we understand this kind of playback so we know how many bytes to read.
                String encoding;
                int bytesPerSample;
                switch (format.getEncoding()) {
                    case AudioFormat.ENCODING_PCM_8BIT:
                        encoding = "8bit";
                        bytesPerSample = 1;
                        break;
                    case AudioFormat.ENCODING_PCM_16BIT:
                        encoding = "16bit";
                        bytesPerSample = 2;
                        break;
                    case AudioFormat.ENCODING_PCM_FLOAT:
                        encoding = "float";
                        bytesPerSample = 4;
                        break;
                    default:
                        throw new RuntimeException("Unhandled audio format in event");
                }

                int bytesRequired = format.getSampleRate() * format.getChannelCount() *
                        bytesPerSample * mModelInfo.captureAudioMs / 1000;
                int minBufferSize = AudioRecord.getMinBufferSize(
                        format.getSampleRate(), format.getChannelMask(), format.getEncoding());
                if (minBufferSize > bytesRequired) {
                    bytesRequired = minBufferSize;
                }

                // Make an AudioTrack so we can play the data back out after it's finished
                // recording.
                try {
                    int channelConfig = AudioFormat.CHANNEL_OUT_MONO;
                    if (format.getChannelCount() == 2) {
                        channelConfig = AudioFormat.CHANNEL_OUT_STEREO;
                    } else if (format.getChannelCount() >= 3) {
                        throw new RuntimeException(
                                "Too many channels in captured audio for playback");
                    }

                    playbackTrack = new AudioTrack(AudioManager.STREAM_MUSIC,
                            format.getSampleRate(), channelConfig, format.getEncoding(),
                            bytesRequired, AudioTrack.MODE_STATIC);
                } catch (Exception e) {
                    Log.e(TAG, "Exception creating playback track", e);
                    postErrorToast("Failed to create playback track: " + e.getMessage());
                }

                audioRecord = new AudioRecord(attributes, format, bytesRequired,
                        mEvent.getCaptureSession());

                byte[] buffer = new byte[bytesRequired];

                // Create a file so we can save the output data there for analysis later.
                FileOutputStream fos  = null;
                try {
                    fos = new FileOutputStream( new File(
                            getFilesDir() + File.separator + mModelInfo.name.replace(' ', '_') +
                                    "_capture_" + format.getChannelCount() + "ch_" +
                                    format.getSampleRate() + "hz_" + encoding + ".pcm"));
                } catch (IOException e) {
                    Log.e(TAG, "Failed to open output for saving PCM data", e);
                    postErrorToast("Failed to open output for saving PCM data: " + e.getMessage());
                }

                // Inform the user we're recording.
                setModelState(mModelInfo, "Recording");
                audioRecord.startRecording();
                while (bytesRequired > 0) {
                    int bytesRead = audioRecord.read(buffer, 0, buffer.length);
                    if (bytesRead == -1) {
                        break;
                    }
                    if (fos != null) {
                        fos.write(buffer, 0, bytesRead);
                    }
                    if (playbackTrack != null) {
                        playbackTrack.write(buffer, 0, bytesRead);
                    }
                    bytesRequired -= bytesRead;
                }
                audioRecord.stop();
            } catch (Exception e) {
                Log.e(TAG, "Error recording trigger audio", e);
                postErrorToast("Error recording trigger audio: " + e.getMessage());
            } finally {
                if (audioRecord != null) {
                    audioRecord.release();
                }
                synchronized (SoundTriggerTestService.this) {
                    if (mModelInfo.captureAudioTrack != null) {
                        mModelInfo.captureAudioTrack.release();
                    }
                    mModelInfo.captureAudioTrack = playbackTrack;
                }
                setModelState(mModelInfo, "Recording finished");
            }
        }
    }

    // Implementation of SoundTriggerDetector.Callback.
    private class DetectorCallback extends SoundTriggerDetector.Callback {
        private final ModelInfo mModelInfo;

        public DetectorCallback(ModelInfo modelInfo) {
            mModelInfo = modelInfo;
        }

        public void onAvailabilityChanged(int status) {
            postMessage(mModelInfo.name + " availability changed to: " + status);
        }

        public void onDetected(SoundTriggerDetector.EventPayload event) {
            postMessage(mModelInfo.name + " onDetected(): " + eventPayloadToString(event));
            synchronized (SoundTriggerTestService.this) {
                if (mUserActivity != null) {
                    mUserActivity.handleDetection(mModelInfo.modelUuid);
                }
                if (mModelInfo.captureAudio) {
                    new Thread(new CaptureAudioRecorder(mModelInfo, event)).start();
                }
            }
        }

        public void onError() {
            postMessage(mModelInfo.name + " onError()");
            setModelState(mModelInfo, "Error");
        }

        public void onRecognitionPaused() {
            postMessage(mModelInfo.name + " onRecognitionPaused()");
            setModelState(mModelInfo, "Paused");
        }

        public void onRecognitionResumed() {
            postMessage(mModelInfo.name + " onRecognitionResumed()");
            setModelState(mModelInfo, "Resumed");
        }
    }

    private String eventPayloadToString(SoundTriggerDetector.EventPayload event) {
        String result = "EventPayload(";
        AudioFormat format =  event.getCaptureAudioFormat();
        result = result + "AudioFormat: " + ((format == null) ? "null" : format.toString());
        byte[] triggerAudio = event.getTriggerAudio();
        result = result + ", TriggerAudio: " + (triggerAudio == null ? "null" : triggerAudio.length);
        byte[] data = event.getData();
        result = result + ", Data: " + (data == null ? "null" : data.length);
        if (data != null) {
          try {
            String decodedData = new String(data, "UTF-8");
            if (decodedData.chars().allMatch(c -> (c >= 32 && c < 128) || c == 0)) {
                result = result + ", Decoded Data: '" + decodedData + "'";
            }
          } catch (Exception e) {
            Log.e(TAG, "Failed to decode data");
          }
        }
        result = result + ", CaptureSession: " + event.getCaptureSession();
        result += " )";
        return result;
    }

    private void postMessage(String msg) {
        showMessage(msg, Log.INFO, false);
    }

    private void postError(String msg) {
        showMessage(msg, Log.ERROR, false);
    }

    private void postToast(String msg) {
        showMessage(msg, Log.INFO, true);
    }

    private void postErrorToast(String msg) {
        showMessage(msg, Log.ERROR, true);
    }

    /** Logs the message at the specified level, then forwards it to the activity if present. */
    private synchronized void showMessage(String msg, int logLevel, boolean showToast) {
        Log.println(logLevel, TAG, msg);
        if (mUserActivity != null) {
            mUserActivity.showMessage(msg, showToast);
        }
    }

    private synchronized void setModelState(ModelInfo modelInfo, String state) {
        modelInfo.state = state;
        if (mUserActivity != null) {
            mUserActivity.setModelState(modelInfo.modelUuid, modelInfo.state);
        }
    }
}
