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;
}
}
}