package org.droidplanner.android.notifications; import android.annotation.SuppressLint; import android.content.BroadcastReceiver; import android.content.Context; import android.content.Intent; import android.content.IntentFilter; import android.os.Build; import android.os.Handler; import android.speech.tts.TextToSpeech; import android.speech.tts.TextToSpeech.OnInitListener; import android.support.v4.content.LocalBroadcastManager; import android.util.Log; import android.widget.Toast; import com.o3dr.android.client.Drone; import com.o3dr.services.android.lib.drone.attribute.AttributeEvent; import com.o3dr.services.android.lib.drone.attribute.AttributeEventExtra; import com.o3dr.services.android.lib.drone.attribute.AttributeType; import com.o3dr.services.android.lib.drone.attribute.error.ErrorType; import com.o3dr.services.android.lib.drone.property.Altitude; import com.o3dr.services.android.lib.drone.property.Battery; import com.o3dr.services.android.lib.drone.property.Gps; import com.o3dr.services.android.lib.drone.property.Signal; import com.o3dr.services.android.lib.drone.property.Speed; import com.o3dr.services.android.lib.drone.property.State; import com.o3dr.services.android.lib.drone.property.VehicleMode; import org.droidplanner.android.R; import org.droidplanner.android.fragments.SettingsFragment; import org.droidplanner.android.utils.prefs.DroidPlannerPrefs; import java.util.ArrayList; import java.util.HashMap; import java.util.List; import java.util.Locale; import java.util.Map; import java.util.Set; import java.util.concurrent.atomic.AtomicBoolean; import timber.log.Timber; /** * Implements DroidPlanner audible notifications. */ public class TTSNotificationProvider implements OnInitListener, NotificationHandler.NotificationProvider { private static final String CLAZZ_NAME = TTSNotificationProvider.class.getName(); private static final String TAG = TTSNotificationProvider.class.getSimpleName(); private static final long WARNING_DELAY = 1500l; //ms private static final double BATTERY_DISCHARGE_NOTIFICATION_EVERY_PERCENT = 10; /** * Utterance id for the periodic status speech. */ private static final String PERIODIC_STATUS_UTTERANCE_ID = "periodic_status_utterance"; /** * Action used for message to be delivered by the tts speech engine. */ public static final String ACTION_SPEAK_MESSAGE = CLAZZ_NAME + ".ACTION_SPEAK_MESSAGE"; public static final String EXTRA_MESSAGE_TO_SPEAK = "extra_message_to_speak"; private final static IntentFilter eventFilter = new IntentFilter(); static { eventFilter.addAction(AttributeEvent.STATE_ARMING); eventFilter.addAction(AttributeEvent.BATTERY_UPDATED); eventFilter.addAction(AttributeEvent.STATE_VEHICLE_MODE); eventFilter.addAction(AttributeEvent.MISSION_SENT); eventFilter.addAction(AttributeEvent.GPS_FIX); eventFilter.addAction(AttributeEvent.MISSION_RECEIVED); eventFilter.addAction(AttributeEvent.HEARTBEAT_FIRST); eventFilter.addAction(AttributeEvent.HEARTBEAT_TIMEOUT); eventFilter.addAction(AttributeEvent.HEARTBEAT_RESTORED); eventFilter.addAction(AttributeEvent.MISSION_ITEM_UPDATED); eventFilter.addAction(AttributeEvent.FOLLOW_START); eventFilter.addAction(AttributeEvent.AUTOPILOT_ERROR); eventFilter.addAction(AttributeEvent.ALTITUDE_UPDATED); eventFilter.addAction(AttributeEvent.SIGNAL_WEAK); eventFilter.addAction(AttributeEvent.WARNING_NO_GPS); eventFilter.addAction(AttributeEvent.HOME_UPDATED); } private final BroadcastReceiver eventReceiver = new BroadcastReceiver() { @Override public void onReceive(Context context, Intent intent) { if (tts == null) return; final String action = intent.getAction(); State droneState = drone.getAttribute(AttributeType.STATE); switch (action) { case AttributeEvent.STATE_ARMING: if (droneState != null) speakArmedState(droneState.isArmed()); break; case AttributeEvent.BATTERY_UPDATED: Battery droneBattery = drone.getAttribute(AttributeType.BATTERY); if (droneBattery != null) batteryDischargeNotification(droneBattery.getBatteryRemain()); break; case AttributeEvent.STATE_VEHICLE_MODE: if (droneState != null) speakMode(droneState.getVehicleMode()); break; case AttributeEvent.MISSION_SENT: Toast.makeText(context, R.string.toast_mission_sent, Toast.LENGTH_SHORT).show(); speak(context.getString(R.string.speak_mission_sent)); break; case AttributeEvent.GPS_FIX: Gps droneGps = drone.getAttribute(AttributeType.GPS); if (droneGps != null) speakGpsMode(droneGps.getFixType()); break; case AttributeEvent.MISSION_RECEIVED: Toast.makeText(context, R.string.toast_mission_received, Toast.LENGTH_SHORT).show(); speak(context.getString(R.string.speak_mission_received)); break; case AttributeEvent.HEARTBEAT_FIRST: speak(context.getString(R.string.speak_heartbeat_first)); break; case AttributeEvent.HEARTBEAT_TIMEOUT: if (mAppPrefs.getWarningOnLostOrRestoredSignal()) { speak(context.getString(R.string.speak_heartbeat_timeout)); handler.removeCallbacks(watchdogCallback); } break; case AttributeEvent.HEARTBEAT_RESTORED: watchdogCallback.setDrone(drone); scheduleWatchdog(); if (mAppPrefs.getWarningOnLostOrRestoredSignal()) { speak(context.getString(R.string.speak_heartbeat_restored)); } break; case AttributeEvent.MISSION_ITEM_UPDATED: int currentWaypoint = intent.getIntExtra(AttributeEventExtra.EXTRA_MISSION_CURRENT_WAYPOINT, 0); if (currentWaypoint != 0) { //Zeroth waypoint is the home location. speak(context.getString(R.string.speak_mission_item_updated, currentWaypoint)); } break; case AttributeEvent.FOLLOW_START: speak(context.getString(R.string.speak_follow_start)); break; case AttributeEvent.ALTITUDE_UPDATED: final Altitude altitude = drone.getAttribute(AttributeType.ALTITUDE); if (mAppPrefs.hasExceededMaxAltitude(altitude.getAltitude())) { if (isMaxAltExceeded.compareAndSet(false, true)) { handler.postDelayed(maxAltitudeExceededWarning, WARNING_DELAY); } } else { handler.removeCallbacks(maxAltitudeExceededWarning); isMaxAltExceeded.set(false); } break; case AttributeEvent.AUTOPILOT_ERROR: if (mAppPrefs.getWarningOnAutopilotWarning()) { String errorId = intent.getStringExtra(AttributeEventExtra.EXTRA_AUTOPILOT_ERROR_ID); final ErrorType errorType = ErrorType.getErrorById(errorId); if (errorType != null && errorType != ErrorType.NO_ERROR) { speak(errorType.getLabel(context).toString()); } } break; case AttributeEvent.SIGNAL_WEAK: if (mAppPrefs.getWarningOnLowSignalStrength()) { speak(context.getString(R.string.speak_warning_signal_weak)); } break; case AttributeEvent.WARNING_NO_GPS: speak(context.getString(R.string.speak_warning_no_gps)); break; case AttributeEvent.HOME_UPDATED: if (droneState.isFlying()) { //Warn the user the home location was just updated while in flight. if (mAppPrefs.getWarningOnVehicleHomeUpdate()) { speak(context.getString(R.string.speak_warning_vehicle_home_updated)); } } break; } } }; private final AtomicBoolean mIsPeriodicStatusStarted = new AtomicBoolean(false); /** * Listens for updates to the status interval. */ private final BroadcastReceiver mSpeechIntervalUpdateReceiver = new BroadcastReceiver() { @Override public void onReceive(Context context, Intent intent) { final String action = intent.getAction(); if (SettingsFragment.ACTION_UPDATED_STATUS_PERIOD.equals(action)) { scheduleWatchdog(); } else if (ACTION_SPEAK_MESSAGE.equals(action)) { String msg = intent.getStringExtra(EXTRA_MESSAGE_TO_SPEAK); if (msg != null) { speak(msg); } } } }; /** * Monitors speech completion. */ private final TextToSpeech.OnUtteranceCompletedListener mSpeechCompleteListener = new TextToSpeech.OnUtteranceCompletedListener() { @Override public void onUtteranceCompleted(String utteranceId) { if (PERIODIC_STATUS_UTTERANCE_ID.equals(utteranceId)) { mIsPeriodicStatusStarted.set(false); } } }; private final AtomicBoolean isMaxAltExceeded = new AtomicBoolean(false); private final Runnable maxAltitudeExceededWarning = new Runnable() { @Override public void run() { speak(context.getString(R.string.speak_warning_max_alt_exceed)); handler.removeCallbacks(maxAltitudeExceededWarning); } }; /** * Stored the parameters to be passed to the tts `speak(...)` method. */ private final HashMap<String, String> mTtsParams = new HashMap<String, String>(); private TextToSpeech tts; private int lastBatteryDischargeNotification; private final Context context; private final DroidPlannerPrefs mAppPrefs; private final Handler handler = new Handler(); private int statusInterval; private class Watchdog implements Runnable { private final StringBuilder mMessageBuilder = new StringBuilder(); private Drone drone; public void run() { handler.removeCallbacks(watchdogCallback); if (drone != null) { final State droneState = drone.getAttribute(AttributeType.STATE); if (droneState.isConnected() && droneState.isArmed()) speakPeriodic(drone); } if (statusInterval != 0) { handler.postDelayed(watchdogCallback, statusInterval * 1000); } } // Periodic status preferences private void speakPeriodic(Drone drone) { // Drop the message if the previous one is not done yet. if (mIsPeriodicStatusStarted.compareAndSet(false, true)) { final Map<String, Boolean> speechPrefs = mAppPrefs.getPeriodicSpeechPrefs(); mMessageBuilder.setLength(0); if (speechPrefs.get(DroidPlannerPrefs.PREF_TTS_PERIODIC_BAT_VOLT)) { final Battery droneBattery = drone.getAttribute(AttributeType.BATTERY); mMessageBuilder.append(context.getString(R.string.periodic_status_bat_volt, droneBattery.getBatteryVoltage())); } if (speechPrefs.get(DroidPlannerPrefs.PREF_TTS_PERIODIC_ALT)) { final Altitude altitude = drone.getAttribute(AttributeType.ALTITUDE); mMessageBuilder.append(context.getString(R.string.periodic_status_altitude, (int) (altitude.getAltitude()))); } if (speechPrefs.get(DroidPlannerPrefs.PREF_TTS_PERIODIC_AIRSPEED)) { final Speed droneSpeed = drone.getAttribute(AttributeType.SPEED); mMessageBuilder.append(context.getString(R.string.periodic_status_airspeed, (int) (droneSpeed.getAirSpeed()))); } if (speechPrefs.get(DroidPlannerPrefs.PREF_TTS_PERIODIC_RSSI)) { final Signal signal = drone.getAttribute(AttributeType.SIGNAL); mMessageBuilder.append(context.getString(R.string.periodic_status_rssi, (int) signal.getRssi())); } speak(mMessageBuilder.toString(), true, PERIODIC_STATUS_UTTERANCE_ID); } } public void setDrone(Drone drone) { this.drone = drone; } } public final Watchdog watchdogCallback = new Watchdog(); private final Drone drone; TTSNotificationProvider(Context context, Drone drone) { this.context = context; this.drone = drone; mAppPrefs = DroidPlannerPrefs.getInstance(context); } @Override public void init() { tts = new TextToSpeech(context, this); LocalBroadcastManager.getInstance(context).registerReceiver(eventReceiver, eventFilter); } @Override public void onTerminate() { LocalBroadcastManager.getInstance(context).unregisterReceiver(eventReceiver); handler.removeCallbacks(watchdogCallback); speak(context.getString(R.string.speak_disconected)); if (tts != null) { tts.shutdown(); tts = null; } } private void scheduleWatchdog() { handler.removeCallbacks(watchdogCallback); statusInterval = mAppPrefs.getSpokenStatusInterval(); if (statusInterval != 0) { handler.postDelayed(watchdogCallback, statusInterval * 1000); } } @SuppressLint("NewApi") @Override public void onInit(int status) { if (tts == null) return; if (status == TextToSpeech.SUCCESS) { // TODO: check if the language is available Locale ttsLanguage; final int sdkVersion = Build.VERSION.SDK_INT; if (sdkVersion >= Build.VERSION_CODES.JELLY_BEAN_MR2) { ttsLanguage = tts.getDefaultLanguage(); } else { ttsLanguage = tts.getLanguage(); } if (ttsLanguage == null || tts.isLanguageAvailable(ttsLanguage) == TextToSpeech.LANG_NOT_SUPPORTED) { ttsLanguage = Locale.US; if (sdkVersion >= Build.VERSION_CODES.LOLLIPOP) { try { final Set<Locale> languagesSet = tts.getAvailableLanguages(); if (languagesSet != null && !languagesSet.isEmpty()) { final List<Locale> availableLanguages = new ArrayList<>(languagesSet); //Pick the first available language. ttsLanguage = availableLanguages.get(0); } } catch(NullPointerException e) { Timber.e(e, "Unable to retrieve available languages."); } } } if (tts.isLanguageAvailable(ttsLanguage) == TextToSpeech.LANG_MISSING_DATA) { context.startActivity(new Intent(TextToSpeech.Engine.ACTION_INSTALL_TTS_DATA) .addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)); } int supportStatus = tts.setLanguage(ttsLanguage); switch (supportStatus) { case TextToSpeech.LANG_MISSING_DATA: case TextToSpeech.LANG_NOT_SUPPORTED: tts.shutdown(); tts = null; Log.e(TAG, "TTS Language data is not available."); Toast.makeText(context, R.string.toast_error_tts_lang, Toast.LENGTH_LONG).show(); break; } if (tts != null) { tts.setOnUtteranceCompletedListener(mSpeechCompleteListener); // Register the broadcast receiver final IntentFilter intentFilter = new IntentFilter(); intentFilter.addAction(ACTION_SPEAK_MESSAGE); intentFilter.addAction(SettingsFragment.ACTION_UPDATED_STATUS_PERIOD); LocalBroadcastManager.getInstance(context).registerReceiver( mSpeechIntervalUpdateReceiver, intentFilter); //Announce the connection event watchdogCallback.setDrone(drone); scheduleWatchdog(); speak(context.getString(R.string.speak_connected)); } } else { // Notify the user that the tts engine is not available. Log.e(TAG, "TextToSpeech initialization failed."); Toast.makeText( context, R.string.warn_tts_accessibility, Toast.LENGTH_LONG).show(); } } private void speak(String string) { speak(string, true, null); } private void speak(String string, boolean append, String utteranceId) { if (tts != null) { if (shouldEnableTTS()) { final int queueType = append ? TextToSpeech.QUEUE_ADD : TextToSpeech.QUEUE_FLUSH; mTtsParams.clear(); if (utteranceId != null) { mTtsParams.put(TextToSpeech.Engine.KEY_PARAM_UTTERANCE_ID, utteranceId); } tts.speak(string, queueType, mTtsParams); } } } private boolean shouldEnableTTS() { return mAppPrefs.isTtsEnabled(); } private void speakArmedState(boolean armed) { if (armed) { speak(context.getString(R.string.speak_armed)); } else { speak(context.getString(R.string.speak_disarmed)); } } private void batteryDischargeNotification(double battRemain) { if (lastBatteryDischargeNotification > (int) ((battRemain - 1) / BATTERY_DISCHARGE_NOTIFICATION_EVERY_PERCENT) || lastBatteryDischargeNotification + 1 < (int) ((battRemain - 1) / BATTERY_DISCHARGE_NOTIFICATION_EVERY_PERCENT)) { lastBatteryDischargeNotification = (int) ((battRemain - 1) / BATTERY_DISCHARGE_NOTIFICATION_EVERY_PERCENT); speak(context.getString(R.string.speak_battery_notification, (int) battRemain)); } } private void speakMode(VehicleMode mode) { if (mode == null) return; String modeString = context.getString(R.string.fly_mode_mode); switch (mode) { case PLANE_FLY_BY_WIRE_A: modeString += context.getString(R.string.fly_mode_wire_a); break; case PLANE_FLY_BY_WIRE_B: modeString += context.getString(R.string.fly_mode_wire_b); break; case COPTER_ACRO: modeString += context.getString(R.string.fly_mode_acro); break; case COPTER_ALT_HOLD: modeString += context.getString(R.string.fly_mode_alt_hold); break; case COPTER_POSHOLD: modeString += context.getString(R.string.fly_mode_pos_hold); break; case PLANE_RTL: case COPTER_RTL: modeString += context.getString(R.string.fly_mode_rtl); break; default: modeString += mode.getLabel(); break; } speak(modeString); } private void speakGpsMode(int fix) { switch (fix) { case 2: speak(context.getString(R.string.gps_mode_2d_lock)); break; case 3: speak(context.getString(R.string.gps_mode_3d_lock)); break; case 4: speak(context.getString(R.string.gps_mode_3d_dgps_lock)); break; case 5: speak(context.getString(R.string.gps_mode_3d_rtk_lock)); break; default: speak(context.getString(R.string.gps_mode_lost_gps_lock)); break; } } }