mirror of
https://codeberg.org/Freeyourgadget/Gadgetbridge
synced 2024-11-14 22:19:29 +01:00
Add Voice Helper support for Alexa
This commit is contained in:
parent
ec73b244ee
commit
608b984119
@ -40,6 +40,11 @@
|
||||
<uses-permission android:name="me.hackerchick.catima.READ_CARDS"/>
|
||||
<uses-permission android:name="me.hackerchick.catima.debug.READ_CARDS"/>
|
||||
|
||||
<!-- GB Voice Helper -->
|
||||
<uses-permission android:name="nodomain.freeyourgadget.voice.VOICE_HELPER"/>
|
||||
<uses-permission android:name="nodomain.freeyourgadget.voice.debug.VOICE_HELPER"/>
|
||||
<uses-permission android:name="nodomain.freeyourgadget.voice.nightly.VOICE_HELPER"/>
|
||||
|
||||
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE"
|
||||
android:maxSdkVersion="32" />
|
||||
<uses-permission android:name="android.permission.READ_CALENDAR" />
|
||||
@ -138,6 +143,10 @@
|
||||
android:name=".activities.discovery.DiscoveryPairingPreferenceActivity"
|
||||
android:label="@string/activity_prefs_discovery_pairing"
|
||||
android:parentActivityName=".activities.SettingsActivity" />
|
||||
<activity
|
||||
android:name=".activities.voice.VoiceHelperSettingsActivity"
|
||||
android:label="@string/activity_prefs_voice_helper"
|
||||
android:parentActivityName=".activities.SettingsActivity" />
|
||||
<activity
|
||||
android:name=".devices.miband.MiBandPreferencesActivity"
|
||||
android:label="@string/preferences_miband_1_2_settings"
|
||||
|
@ -0,0 +1,12 @@
|
||||
package nodomain.freeyourgadget.voice;
|
||||
|
||||
interface IOpusCodecService {
|
||||
int version();
|
||||
|
||||
String create();
|
||||
void destroy(String codec);
|
||||
|
||||
int decoderInit(String codec, int sampleRate, int channels);
|
||||
int decode(String codec, in byte[] data, int len, out byte[] pcm, int frameSize, int decodeFec);
|
||||
void decoderDestroy(String codec);
|
||||
}
|
@ -64,6 +64,7 @@ import nodomain.freeyourgadget.gadgetbridge.GBApplication;
|
||||
import nodomain.freeyourgadget.gadgetbridge.R;
|
||||
import nodomain.freeyourgadget.gadgetbridge.activities.charts.ChartsPreferencesActivity;
|
||||
import nodomain.freeyourgadget.gadgetbridge.activities.discovery.DiscoveryPairingPreferenceActivity;
|
||||
import nodomain.freeyourgadget.gadgetbridge.activities.voice.VoiceHelperSettingsActivity;
|
||||
import nodomain.freeyourgadget.gadgetbridge.database.PeriodicExporter;
|
||||
import nodomain.freeyourgadget.gadgetbridge.devices.hplus.HPlusSettingsActivity;
|
||||
import nodomain.freeyourgadget.gadgetbridge.devices.miband.MiBandPreferencesActivity;
|
||||
@ -440,6 +441,15 @@ public class SettingsActivity extends AbstractSettingsActivityV2 {
|
||||
});
|
||||
}
|
||||
|
||||
pref = findPreference("pref_voice_helper");
|
||||
if (pref != null) {
|
||||
pref.setOnPreferenceClickListener(preference -> {
|
||||
Intent enableIntent = new Intent(requireContext(), VoiceHelperSettingsActivity.class);
|
||||
startActivity(enableIntent);
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
//fitness app (OpenTracks) package name selection for OpenTracks observer
|
||||
pref = findPreference("pref_key_opentracks_packagename");
|
||||
if (pref != null) {
|
||||
|
@ -0,0 +1,70 @@
|
||||
/* Copyright (C) 2023 José Rebelo
|
||||
|
||||
This file is part of Gadgetbridge.
|
||||
|
||||
Gadgetbridge is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as published
|
||||
by the Free Software Foundation, either version 3 of the License, or
|
||||
(at your option) any later version.
|
||||
|
||||
Gadgetbridge is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <http://www.gnu.org/licenses/>. */
|
||||
package nodomain.freeyourgadget.gadgetbridge.activities.voice;
|
||||
|
||||
import android.content.pm.PackageManager;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.core.app.ActivityCompat;
|
||||
import androidx.fragment.app.Fragment;
|
||||
import androidx.fragment.app.FragmentManager;
|
||||
import androidx.preference.PreferenceFragmentCompat;
|
||||
|
||||
import nodomain.freeyourgadget.gadgetbridge.activities.AbstractSettingsActivityV2;
|
||||
|
||||
public class VoiceHelperSettingsActivity extends AbstractSettingsActivityV2 implements
|
||||
PreferenceFragmentCompat.OnPreferenceStartScreenCallback,
|
||||
ActivityCompat.OnRequestPermissionsResultCallback {
|
||||
public static final int PERMISSION_REQUEST_CODE = 0;
|
||||
|
||||
@Override
|
||||
protected String fragmentTag() {
|
||||
return VoiceHelperSettingsFragment.FRAGMENT_TAG;
|
||||
}
|
||||
|
||||
@Override
|
||||
protected PreferenceFragmentCompat newFragment() {
|
||||
return new VoiceHelperSettingsFragment();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onRequestPermissionsResult(final int requestCode,
|
||||
@NonNull final String[] permissions,
|
||||
@NonNull final int[] grantResults) {
|
||||
super.onRequestPermissionsResult(requestCode, permissions, grantResults);
|
||||
|
||||
if (requestCode != PERMISSION_REQUEST_CODE) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (grantResults.length == 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (grantResults[0] == PackageManager.PERMISSION_GRANTED) {
|
||||
final FragmentManager fragmentManager = getSupportFragmentManager();
|
||||
final Fragment fragment = fragmentManager.findFragmentByTag(VoiceHelperSettingsFragment.FRAGMENT_TAG);
|
||||
if (fragment == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (fragment instanceof VoiceHelperSettingsFragment) {
|
||||
((VoiceHelperSettingsFragment) fragment).reloadPreferences(null);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
@ -0,0 +1,24 @@
|
||||
/* Copyright (C) 2022 José Rebelo
|
||||
|
||||
This file is part of Gadgetbridge.
|
||||
|
||||
Gadgetbridge is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as published
|
||||
by the Free Software Foundation, either version 3 of the License, or
|
||||
(at your option) any later version.
|
||||
|
||||
Gadgetbridge is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <http://www.gnu.org/licenses/>. */
|
||||
package nodomain.freeyourgadget.gadgetbridge.activities.voice;
|
||||
|
||||
public final class VoiceHelperSettingsConst {
|
||||
public static final String VOICE_HELPER_PACKAGE = "voice_helper_package";
|
||||
public static final String VOICE_HELPER_NOT_INSTALLED = "voice_helper_not_installed";
|
||||
public static final String VOICE_HELPER_INSTALL = "voice_helper_install";
|
||||
public static final String VOICE_HELPER_PERMISSIONS = "voice_helper_permissions";
|
||||
}
|
@ -0,0 +1,130 @@
|
||||
/* Copyright (C) 2023 José Rebelo
|
||||
|
||||
This file is part of Gadgetbridge.
|
||||
|
||||
Gadgetbridge is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as published
|
||||
by the Free Software Foundation, either version 3 of the License, or
|
||||
(at your option) any later version.
|
||||
|
||||
Gadgetbridge is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <http://www.gnu.org/licenses/>. */
|
||||
package nodomain.freeyourgadget.gadgetbridge.activities.voice;
|
||||
|
||||
import static nodomain.freeyourgadget.gadgetbridge.activities.voice.VoiceHelperSettingsConst.VOICE_HELPER_INSTALL;
|
||||
import static nodomain.freeyourgadget.gadgetbridge.activities.voice.VoiceHelperSettingsConst.VOICE_HELPER_NOT_INSTALLED;
|
||||
import static nodomain.freeyourgadget.gadgetbridge.activities.voice.VoiceHelperSettingsConst.VOICE_HELPER_PACKAGE;
|
||||
import static nodomain.freeyourgadget.gadgetbridge.activities.voice.VoiceHelperSettingsConst.VOICE_HELPER_PERMISSIONS;
|
||||
|
||||
import android.content.ActivityNotFoundException;
|
||||
import android.content.Intent;
|
||||
import android.content.pm.PackageManager;
|
||||
import android.net.Uri;
|
||||
import android.os.Bundle;
|
||||
import android.widget.Toast;
|
||||
|
||||
import androidx.core.app.ActivityCompat;
|
||||
import androidx.core.content.ContextCompat;
|
||||
import androidx.preference.ListPreference;
|
||||
import androidx.preference.Preference;
|
||||
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
import nodomain.freeyourgadget.gadgetbridge.R;
|
||||
import nodomain.freeyourgadget.gadgetbridge.activities.AbstractPreferenceFragment;
|
||||
import nodomain.freeyourgadget.gadgetbridge.util.GB;
|
||||
import nodomain.freeyourgadget.gadgetbridge.util.StringUtils;
|
||||
import nodomain.freeyourgadget.gadgetbridge.voice.VoiceHelper;
|
||||
|
||||
public class VoiceHelperSettingsFragment extends AbstractPreferenceFragment {
|
||||
private static final Logger LOG = LoggerFactory.getLogger(VoiceHelperSettingsFragment.class);
|
||||
|
||||
static final String FRAGMENT_TAG = "VOICE_HELPER_SETTINGS_FRAGMENT";
|
||||
|
||||
@Override
|
||||
public void onCreatePreferences(final Bundle savedInstanceState, final String rootKey) {
|
||||
setPreferencesFromResource(R.xml.voice_helper, rootKey);
|
||||
|
||||
reloadPreferences(null);
|
||||
}
|
||||
|
||||
protected void reloadPreferences(final String voiceHelperPackageName) {
|
||||
final List<CharSequence> installedPackages = VoiceHelper.findInstalledPackages(requireContext());
|
||||
final boolean voiceHelperInstalled = !installedPackages.isEmpty();
|
||||
|
||||
final ListPreference packagePreference = findPreference(VOICE_HELPER_PACKAGE);
|
||||
|
||||
if (packagePreference != null) {
|
||||
packagePreference.setEntries(installedPackages.toArray(new CharSequence[0]));
|
||||
packagePreference.setEntryValues(installedPackages.toArray(new CharSequence[0]));
|
||||
packagePreference.setVisible(!installedPackages.isEmpty());
|
||||
packagePreference.setOnPreferenceChangeListener((preference, newValue) -> {
|
||||
LOG.info("Voice Helper package preference changed to {}", newValue);
|
||||
reloadPreferences((String) newValue);
|
||||
return true;
|
||||
});
|
||||
|
||||
if (voiceHelperInstalled) {
|
||||
// Ensure the currently selected value is actually an installed package
|
||||
if (StringUtils.isNullOrEmpty(packagePreference.getValue()) || !installedPackages.contains(packagePreference.getValue())) {
|
||||
packagePreference.setValue(installedPackages.get(0).toString());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
final String finalPackageName;
|
||||
|
||||
if (voiceHelperPackageName != null) {
|
||||
finalPackageName = voiceHelperPackageName;
|
||||
} else if (packagePreference != null) {
|
||||
finalPackageName = packagePreference.getValue();
|
||||
} else {
|
||||
LOG.warn("This should never happen - package not found");
|
||||
finalPackageName = "this.should.never.happen";
|
||||
}
|
||||
|
||||
final Preference notInstalledPreference = findPreference(VOICE_HELPER_NOT_INSTALLED);
|
||||
if (notInstalledPreference != null) {
|
||||
notInstalledPreference.setVisible(!voiceHelperInstalled);
|
||||
}
|
||||
|
||||
final Preference installPreference = findPreference(VOICE_HELPER_INSTALL);
|
||||
if (installPreference != null) {
|
||||
installPreference.setVisible(!voiceHelperInstalled);
|
||||
installPreference.setOnPreferenceClickListener(preference -> {
|
||||
installVoiceHelper();
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
final boolean permissionGranted = ContextCompat.checkSelfPermission(requireContext(), VoiceHelper.getPermission(finalPackageName)) == PackageManager.PERMISSION_GRANTED;
|
||||
final Preference permissionsPreference = findPreference(VOICE_HELPER_PERMISSIONS);
|
||||
if (permissionsPreference != null) {
|
||||
permissionsPreference.setVisible(voiceHelperInstalled && !permissionGranted);
|
||||
permissionsPreference.setOnPreferenceClickListener(preference -> {
|
||||
ActivityCompat.requestPermissions(
|
||||
requireActivity(),
|
||||
new String[]{VoiceHelper.getPermission(finalPackageName)},
|
||||
VoiceHelperSettingsActivity.PERMISSION_REQUEST_CODE
|
||||
);
|
||||
return true;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private void installVoiceHelper() {
|
||||
try {
|
||||
startActivity(new Intent(Intent.ACTION_VIEW, Uri.parse("market://details?id=nodomain.freeyourgadget.voice")));
|
||||
} catch (final ActivityNotFoundException e) {
|
||||
GB.toast(requireContext(), requireContext().getString(R.string.voice_helper_install_fail), Toast.LENGTH_LONG, GB.WARN);
|
||||
}
|
||||
}
|
||||
}
|
@ -17,9 +17,12 @@
|
||||
package nodomain.freeyourgadget.gadgetbridge.service.devices.huami.zeppos.services;
|
||||
|
||||
import static nodomain.freeyourgadget.gadgetbridge.activities.devicesettings.DeviceSettingsPreferenceConst.PREF_VOICE_SERVICE_LANGUAGE;
|
||||
import static nodomain.freeyourgadget.gadgetbridge.activities.devicesettings.DeviceSettingsPreferenceConst.PREF_WATCHFACE;
|
||||
|
||||
import android.media.AudioFormat;
|
||||
import android.media.AudioManager;
|
||||
import android.media.AudioTrack;
|
||||
import android.os.Handler;
|
||||
import android.os.RemoteException;
|
||||
import android.widget.Toast;
|
||||
|
||||
import org.slf4j.Logger;
|
||||
@ -44,8 +47,10 @@ import nodomain.freeyourgadget.gadgetbridge.service.devices.huami.zeppos.Abstrac
|
||||
import nodomain.freeyourgadget.gadgetbridge.util.GB;
|
||||
import nodomain.freeyourgadget.gadgetbridge.util.Prefs;
|
||||
import nodomain.freeyourgadget.gadgetbridge.util.StringUtils;
|
||||
import nodomain.freeyourgadget.gadgetbridge.voice.OpusCodec;
|
||||
import nodomain.freeyourgadget.gadgetbridge.voice.VoiceHelper;
|
||||
|
||||
public class ZeppOsAlexaService extends AbstractZeppOsService {
|
||||
public class ZeppOsAlexaService extends AbstractZeppOsService implements VoiceHelper.Callback {
|
||||
private static final Logger LOG = LoggerFactory.getLogger(ZeppOsAlexaService.class);
|
||||
|
||||
private static final short ENDPOINT = 0x0011;
|
||||
@ -71,14 +76,35 @@ public class ZeppOsAlexaService extends AbstractZeppOsService {
|
||||
private static final byte COMPLEX_REPLY_REMINDER = 0x02;
|
||||
private static final byte COMPLEX_REPLY_RICH_TEXT = 0x06;
|
||||
|
||||
private static final byte ERROR_TIMEOUT = 0x01;
|
||||
private static final byte ERROR_NO_INTERNET = 0x03;
|
||||
private static final byte ERROR_NOT_UNDERSTAND = 0x05;
|
||||
private static final byte ERROR_UNAUTHORIZED = 0x06;
|
||||
|
||||
private static final int CHANNELS = 1;
|
||||
private static final int MAX_FRAME_SIZE = 6 * 960;
|
||||
|
||||
public static final String PREF_VERSION = "zepp_os_alexa_version";
|
||||
|
||||
private final Handler handler = new Handler();
|
||||
private boolean pendingStartAck;
|
||||
|
||||
final ByteArrayOutputStream voiceBuffer = new ByteArrayOutputStream();
|
||||
private VoiceHelper voiceHelper;
|
||||
private OpusCodec opusCodec;
|
||||
|
||||
private final AudioTrack audio = new AudioTrack(AudioManager.STREAM_MUSIC,
|
||||
16000,
|
||||
AudioFormat.CHANNEL_OUT_MONO,
|
||||
AudioFormat.ENCODING_PCM_16BIT,
|
||||
AudioTrack.getMinBufferSize(
|
||||
16000,
|
||||
AudioFormat.CHANNEL_OUT_MONO,
|
||||
AudioFormat.ENCODING_PCM_16BIT
|
||||
),
|
||||
AudioTrack.MODE_STREAM
|
||||
);
|
||||
|
||||
final ByteBuffer voiceBuffer = ByteBuffer.allocate(4096);
|
||||
|
||||
public ZeppOsAlexaService(final Huami2021Support support) {
|
||||
super(support);
|
||||
@ -349,22 +375,71 @@ public class ZeppOsAlexaService extends AbstractZeppOsService {
|
||||
final byte var4 = payload[4];
|
||||
final String params = StringUtils.untilNullTerminator(payload, 5);
|
||||
|
||||
// These might be relevant for the remaining connection, but not sure what they mean
|
||||
// On the GTR 4: var1=4, var2=14, var3=2, var4=2, params={"translate":[],"countdown":1440,"thirdMusicApp":0,"ttsTextLimit":0,"suggestions":0,"news":0,"maxAlarmCount":3,"supportDeleteAlert":1,"alexaLanguage":"en-US","audioSize":4096}
|
||||
LOG.info("Alexa starting: var1={}, var2={}, var3={}, var4={}, params={}", var1, var2, var3, var4, params);
|
||||
|
||||
// Send the start ack with a slight delay, to give enough time for the connection to switch to fast mode
|
||||
// I can't seem to get the callback for onConnectionUpdated working, and if we reply too soon the watch
|
||||
// will just stay stuck "Connecting...". It seems like it takes ~350ms to switch to fast connection.
|
||||
handler.postDelayed(this::sendStartAck, 700);
|
||||
voiceBuffer.clear();
|
||||
|
||||
if (voiceHelper == null) {
|
||||
// Lazy initialization of the voice helper
|
||||
voiceHelper = new VoiceHelper(getContext(), this);
|
||||
}
|
||||
|
||||
if (!voiceHelper.isConnected()) {
|
||||
pendingStartAck = true;
|
||||
voiceHelper.connect();
|
||||
} else {
|
||||
// Send the start ack with a slight delay, to give enough time for the connection to switch to fast mode
|
||||
// I can't seem to get the callback for onConnectionUpdated working, and if we reply too soon the watch
|
||||
// will just stay stuck "Connecting...". It seems like it takes ~350ms to switch to fast connection.
|
||||
handler.postDelayed(this::sendStartAck, 700);
|
||||
}
|
||||
audio.play();
|
||||
}
|
||||
|
||||
private void handleEnd(final byte[] payload) {
|
||||
voiceBuffer.reset();
|
||||
voiceBuffer.clear();
|
||||
audio.stop();
|
||||
// TODO do something else?
|
||||
}
|
||||
|
||||
private void handleVoiceData(final byte[] payload) {
|
||||
LOG.info("Got {} bytes of voice data", payload.length);
|
||||
// TODO
|
||||
this.voiceBuffer.put(payload, 5, payload.length - 5);
|
||||
|
||||
int frameSize = this.voiceBuffer.get(0) & 0xff;
|
||||
|
||||
while (voiceBuffer.position() > frameSize) {
|
||||
this.voiceBuffer.flip();
|
||||
frameSize = this.voiceBuffer.get() & 0xff;
|
||||
final byte[] frame = new byte[frameSize];
|
||||
this.voiceBuffer.get(frame);
|
||||
|
||||
LOG.debug("Voice Data Frame: {}", GB.hexdump(frame));
|
||||
|
||||
if (opusCodec != null) {
|
||||
try {
|
||||
final byte[] pcm = new byte[MAX_FRAME_SIZE * CHANNELS * 2];
|
||||
int ret = opusCodec.decode(frame, frame.length, pcm, MAX_FRAME_SIZE, 0);
|
||||
LOG.debug("Opus decode: {}", ret);
|
||||
audio.write(pcm, 0, ret * 2 /* 16 bit */);
|
||||
} catch (final Exception e) {
|
||||
LOG.error("Failed to process opus frame", e);
|
||||
}
|
||||
}
|
||||
|
||||
final ByteBuffer remaining = this.voiceBuffer.slice();
|
||||
this.voiceBuffer.clear();
|
||||
|
||||
if (remaining.limit() > 0) {
|
||||
LOG.debug("Got {} bytes remaining in the voice buffer", remaining.limit());
|
||||
this.voiceBuffer.put(remaining);
|
||||
}
|
||||
|
||||
frameSize = this.voiceBuffer.get(0) & 0xff;
|
||||
LOG.debug("new frameSize: {}", frameSize);
|
||||
}
|
||||
}
|
||||
|
||||
private void handleLanguagesResponse(final byte[] payload) {
|
||||
@ -392,4 +467,30 @@ public class ZeppOsAlexaService extends AbstractZeppOsService {
|
||||
public static boolean isSupported(final Prefs devicePrefs) {
|
||||
return devicePrefs.getInt(PREF_VERSION, 0) == 3;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onVoiceHelperConnection(final boolean connected) {
|
||||
if (connected) {
|
||||
try {
|
||||
opusCodec = voiceHelper.createOpusCodec();
|
||||
int ret = opusCodec.decoderInit(16000, 1);
|
||||
if (ret != 0) {
|
||||
LOG.error("Failed to initialize opus codec, err = {}", ret);
|
||||
if (pendingStartAck) {
|
||||
pendingStartAck = false;
|
||||
handler.postDelayed(() -> sendError(ERROR_TIMEOUT, "Failed to initialize codec"), 700);
|
||||
}
|
||||
}
|
||||
} catch (final Exception e) {
|
||||
LOG.error("Failed to create opus codec", e);
|
||||
handler.postDelayed(this::sendStartAck, 700);
|
||||
}
|
||||
if (pendingStartAck) {
|
||||
pendingStartAck = false;
|
||||
handler.postDelayed(this::sendStartAck, 700);
|
||||
}
|
||||
} else {
|
||||
opusCodec = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -42,6 +42,9 @@ import android.graphics.Color;
|
||||
import android.graphics.Paint;
|
||||
import android.graphics.drawable.Drawable;
|
||||
import android.location.Location;
|
||||
import android.media.AudioFormat;
|
||||
import android.media.AudioManager;
|
||||
import android.media.AudioTrack;
|
||||
import android.net.Uri;
|
||||
import android.os.Bundle;
|
||||
import android.os.IBinder;
|
||||
@ -175,8 +178,10 @@ import nodomain.freeyourgadget.gadgetbridge.util.Prefs;
|
||||
import nodomain.freeyourgadget.gadgetbridge.util.StringUtils;
|
||||
import nodomain.freeyourgadget.gadgetbridge.util.UriHelper;
|
||||
import nodomain.freeyourgadget.gadgetbridge.util.Version;
|
||||
import nodomain.freeyourgadget.gadgetbridge.voice.OpusCodec;
|
||||
import nodomain.freeyourgadget.gadgetbridge.voice.VoiceHelper;
|
||||
|
||||
public class FossilHRWatchAdapter extends FossilWatchAdapter {
|
||||
public class FossilHRWatchAdapter extends FossilWatchAdapter implements VoiceHelper.Callback {
|
||||
public static final int MESSAGE_WHAT_VOICE_DATA_RECEIVED = 0;
|
||||
|
||||
private byte[] phoneRandomNumber;
|
||||
@ -205,24 +210,23 @@ public class FossilHRWatchAdapter extends FossilWatchAdapter {
|
||||
|
||||
List<ApplicationInformation> installedApplications = new ArrayList();
|
||||
|
||||
Messenger voiceMessenger = null;
|
||||
private VoiceHelper voiceHelper;
|
||||
private OpusCodec opusCodec;
|
||||
|
||||
private final AudioTrack audio = new AudioTrack(AudioManager.STREAM_MUSIC,
|
||||
48000,
|
||||
AudioFormat.CHANNEL_OUT_MONO,
|
||||
AudioFormat.ENCODING_PCM_16BIT,
|
||||
AudioTrack.getMinBufferSize(
|
||||
48000,
|
||||
AudioFormat.CHANNEL_OUT_MONO,
|
||||
AudioFormat.ENCODING_PCM_16BIT
|
||||
),
|
||||
AudioTrack.MODE_STREAM
|
||||
);
|
||||
|
||||
private Version cleanFirmwareVersion = null;
|
||||
|
||||
ServiceConnection voiceServiceConnection = new ServiceConnection() {
|
||||
@Override
|
||||
public void onServiceConnected(ComponentName name, IBinder service) {
|
||||
GB.log("attached to voice service", GB.INFO, null);
|
||||
voiceMessenger = new Messenger(service);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onServiceDisconnected(ComponentName name) {
|
||||
GB.log("detached from voice service", GB.INFO, null);
|
||||
voiceMessenger = null;
|
||||
}
|
||||
};
|
||||
|
||||
enum CONNECTION_MODE {
|
||||
NOT_INITIALIZED,
|
||||
AUTHENTICATED,
|
||||
@ -362,69 +366,20 @@ public class FossilHRWatchAdapter extends FossilWatchAdapter {
|
||||
}
|
||||
|
||||
private void attachToVoiceService(){
|
||||
String servicePackage = getDeviceSpecificPreferences().getString("voice_service_package", "");
|
||||
String servicePath = getDeviceSpecificPreferences().getString("voice_service_class", "");
|
||||
|
||||
if(servicePackage.isEmpty()){
|
||||
GB.toast("voice service package is not configured", Toast.LENGTH_LONG, GB.ERROR);
|
||||
respondToAlexa("voice service package not configured on phone", true);
|
||||
return;
|
||||
}
|
||||
|
||||
if(servicePath.isEmpty()){
|
||||
respondToAlexa("voice service class not configured on phone", true);
|
||||
GB.toast("voice service class is not configured", Toast.LENGTH_LONG, GB.ERROR);
|
||||
return;
|
||||
}
|
||||
|
||||
ComponentName component = new ComponentName(servicePackage, servicePath);
|
||||
|
||||
// extract to somewhere
|
||||
Intent voiceIntent = new Intent("nodomain.freeyourgadget.gadgetbridge.VOICE_COMMAND");
|
||||
voiceIntent.setComponent(component);
|
||||
|
||||
int flags = 0;
|
||||
|
||||
flags |= Service.BIND_AUTO_CREATE;
|
||||
|
||||
GB.log("binding to voice service...", GB.INFO, null);
|
||||
|
||||
getContext().bindService(
|
||||
voiceIntent,
|
||||
voiceServiceConnection,
|
||||
flags
|
||||
);
|
||||
|
||||
PackageManager pm = getContext().getPackageManager();
|
||||
boolean serviceEnabled = true;
|
||||
try {
|
||||
int enabledState = pm.getComponentEnabledSetting(component);
|
||||
|
||||
if(enabledState != PackageManager.COMPONENT_ENABLED_STATE_ENABLED && enabledState != PackageManager.COMPONENT_ENABLED_STATE_DEFAULT){
|
||||
respondToAlexa("voice service is disabled on phone", true);
|
||||
GB.toast("voice service is disabled", Toast.LENGTH_LONG, GB.ERROR);
|
||||
serviceEnabled = false;
|
||||
}
|
||||
}catch (IllegalArgumentException e){
|
||||
serviceEnabled = false;
|
||||
respondToAlexa("voice service not found on phone", true);
|
||||
GB.toast("voice service not found", Toast.LENGTH_LONG, GB.ERROR);
|
||||
}
|
||||
|
||||
if(!serviceEnabled){
|
||||
detachFromVoiceService();
|
||||
}
|
||||
voiceHelper.connect();
|
||||
}
|
||||
|
||||
private void detachFromVoiceService(){
|
||||
getContext().unbindService(voiceServiceConnection);
|
||||
// TODO
|
||||
}
|
||||
|
||||
private void handleVoiceStatus(byte status){
|
||||
if(status == 0x00){
|
||||
attachToVoiceService();
|
||||
audio.play();
|
||||
}else if(status == 0x01){
|
||||
detachFromVoiceService();
|
||||
audio.stop();
|
||||
}
|
||||
}
|
||||
|
||||
@ -434,25 +389,43 @@ public class FossilHRWatchAdapter extends FossilWatchAdapter {
|
||||
}
|
||||
|
||||
private void handleVoiceDataCharacteristic(BluetoothGattCharacteristic characteristic){
|
||||
if(voiceMessenger == null){
|
||||
if (opusCodec == null){
|
||||
return;
|
||||
}
|
||||
Message message = Message.obtain(
|
||||
null,
|
||||
MESSAGE_WHAT_VOICE_DATA_RECEIVED
|
||||
);
|
||||
Bundle dataBundle = new Bundle(1);
|
||||
dataBundle.putByteArray("VOICE_DATA", characteristic.getValue());
|
||||
dataBundle.putString("VOICE_ENCODING", "OPUS");
|
||||
message.setData(dataBundle);
|
||||
try {
|
||||
voiceMessenger.send(message);
|
||||
} catch (RemoteException e) {
|
||||
GB.log("error sending voice data to service", GB.ERROR, e);
|
||||
GB.toast("error sending voice data to service", Toast.LENGTH_LONG, GB.ERROR);
|
||||
voiceMessenger = null;
|
||||
detachFromVoiceService();
|
||||
respondToAlexa("error sending voice data to service", true);
|
||||
final int CHANNELS = 1;
|
||||
final int MAX_FRAME_SIZE = 6 * 960;
|
||||
|
||||
final ByteBuffer buf = ByteBuffer.wrap(characteristic.getValue());
|
||||
|
||||
final byte[] frame = new byte[960];
|
||||
|
||||
while (buf.position() < buf.limit()) {
|
||||
buf.get(frame);
|
||||
try {
|
||||
final byte[] pcm = new byte[MAX_FRAME_SIZE * CHANNELS * 2];
|
||||
int ret = opusCodec.decode(frame, frame.length, pcm, MAX_FRAME_SIZE, 0);
|
||||
LOG.debug("Opus decode: {}", ret);
|
||||
audio.write(pcm, 0, ret * 2 /* 16 bit */);
|
||||
} catch (final Exception e) {
|
||||
LOG.error("Failed to process opus frame", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onVoiceHelperConnection(boolean connected) {
|
||||
if (connected) {
|
||||
try {
|
||||
opusCodec = voiceHelper.createOpusCodec();
|
||||
int ret = opusCodec.decoderInit(48000, 1);
|
||||
if (ret != 0) {
|
||||
LOG.error("Failed to initialize opus codec, err = {}", ret);
|
||||
}
|
||||
} catch (final Exception e) {
|
||||
LOG.error("Failed to create opus codec", e);
|
||||
}
|
||||
} else {
|
||||
opusCodec = null;
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -0,0 +1,47 @@
|
||||
/* Copyright (C) 2023 José Rebelo
|
||||
|
||||
This file is part of Gadgetbridge.
|
||||
|
||||
Gadgetbridge is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as published
|
||||
by the Free Software Foundation, either version 3 of the License, or
|
||||
(at your option) any later version.
|
||||
|
||||
Gadgetbridge is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <http://www.gnu.org/licenses/>. */
|
||||
package nodomain.freeyourgadget.gadgetbridge.voice;
|
||||
|
||||
import android.os.RemoteException;
|
||||
|
||||
import nodomain.freeyourgadget.voice.IOpusCodecService;
|
||||
|
||||
public class OpusCodec {
|
||||
private final IOpusCodecService mOpusCodecService;
|
||||
private final String codec;
|
||||
|
||||
public OpusCodec(final IOpusCodecService opusCodecService) throws RemoteException {
|
||||
this.mOpusCodecService = opusCodecService;
|
||||
this.codec = opusCodecService.create();
|
||||
}
|
||||
|
||||
public void destroy() throws RemoteException {
|
||||
this.mOpusCodecService.destroy(codec);
|
||||
}
|
||||
|
||||
public int decoderInit(final int sampleRate, final int channels) throws RemoteException {
|
||||
return mOpusCodecService.decoderInit(codec, sampleRate, channels);
|
||||
}
|
||||
|
||||
public int decode(final byte[] data, final int len, final byte[] pcm, final int frameSize, final int decodeFec) throws RemoteException {
|
||||
return mOpusCodecService.decode(codec, data, len, pcm, frameSize, decodeFec);
|
||||
}
|
||||
|
||||
public void decoderDestroy() throws RemoteException {
|
||||
mOpusCodecService.decoderDestroy(codec);
|
||||
}
|
||||
}
|
@ -0,0 +1,135 @@
|
||||
/* Copyright (C) 2023 José Rebelo
|
||||
|
||||
This file is part of Gadgetbridge.
|
||||
|
||||
Gadgetbridge is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as published
|
||||
by the Free Software Foundation, either version 3 of the License, or
|
||||
(at your option) any later version.
|
||||
|
||||
Gadgetbridge is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <http://www.gnu.org/licenses/>. */
|
||||
package nodomain.freeyourgadget.gadgetbridge.voice;
|
||||
|
||||
import static nodomain.freeyourgadget.gadgetbridge.activities.voice.VoiceHelperSettingsConst.VOICE_HELPER_PACKAGE;
|
||||
|
||||
import android.content.ComponentName;
|
||||
import android.content.Context;
|
||||
import android.content.Intent;
|
||||
import android.content.ServiceConnection;
|
||||
import android.content.pm.PackageManager;
|
||||
import android.os.IBinder;
|
||||
import android.os.RemoteException;
|
||||
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
|
||||
import nodomain.freeyourgadget.gadgetbridge.GBApplication;
|
||||
import nodomain.freeyourgadget.gadgetbridge.util.Prefs;
|
||||
import nodomain.freeyourgadget.voice.IOpusCodecService;
|
||||
|
||||
public class VoiceHelper {
|
||||
private static final Logger LOG = LoggerFactory.getLogger(VoiceHelper.class);
|
||||
|
||||
public static final List<String> KNOWN_PACKAGES = new ArrayList<String>() {{
|
||||
add("nodomain.freeyourgadget.voice");
|
||||
add("nodomain.freeyourgadget.voice.debug");
|
||||
add("nodomain.freeyourgadget.voice.nightly");
|
||||
}};
|
||||
|
||||
private final Context mContext;
|
||||
private final Callback mCallback;
|
||||
private final String packageName;
|
||||
|
||||
private ServiceConnection mConnection;
|
||||
private IOpusCodecService mOpusCodecService;
|
||||
|
||||
public VoiceHelper(final Context context, final Callback callback) {
|
||||
this.mContext = context;
|
||||
this.mCallback = callback;
|
||||
|
||||
final List<CharSequence> installedPackages = findInstalledPackages(mContext);
|
||||
if (installedPackages.isEmpty()) {
|
||||
LOG.warn("Voice Helper is not installed");
|
||||
this.packageName = null;
|
||||
return;
|
||||
}
|
||||
final Prefs prefs = GBApplication.getPrefs();
|
||||
this.packageName = prefs.getString(VOICE_HELPER_PACKAGE, installedPackages.get(0).toString());
|
||||
}
|
||||
|
||||
public void connect() {
|
||||
LOG.info("Connecting to Voice Helper");
|
||||
|
||||
mConnection = new ServiceConnection() {
|
||||
public void onServiceConnected(final ComponentName className, final IBinder service) {
|
||||
LOG.info("onServiceConnected {}", className);
|
||||
|
||||
mOpusCodecService = IOpusCodecService.Stub.asInterface(service);
|
||||
mCallback.onVoiceHelperConnection(true);
|
||||
}
|
||||
|
||||
// Called when the connection with the service disconnects unexpectedly.
|
||||
public void onServiceDisconnected(final ComponentName className) {
|
||||
LOG.error("Service has unexpectedly disconnected");
|
||||
mOpusCodecService = null;
|
||||
mCallback.onVoiceHelperConnection(false);
|
||||
}
|
||||
};
|
||||
final Intent intent = new Intent("nodomain.freeyourgadget.voice.OpusCodecService");
|
||||
intent.setPackage(packageName);
|
||||
boolean res = mContext.bindService(intent, mConnection, Context.BIND_AUTO_CREATE);
|
||||
if (res) {
|
||||
LOG.info("Bound to Voice Helper");
|
||||
} else {
|
||||
LOG.warn("Could not bind to Voice Helper");
|
||||
}
|
||||
}
|
||||
|
||||
public void disconnect() {
|
||||
mContext.unbindService(mConnection);
|
||||
}
|
||||
|
||||
public boolean isConnected() {
|
||||
return mOpusCodecService != null;
|
||||
}
|
||||
|
||||
public OpusCodec createOpusCodec() throws RemoteException {
|
||||
return new OpusCodec(mOpusCodecService);
|
||||
}
|
||||
|
||||
public static String getPermission(final String packageName) {
|
||||
return String.format(Locale.ROOT, "%s.VOICE_HELPER", packageName);
|
||||
}
|
||||
|
||||
public static List<CharSequence> findInstalledPackages(final Context context) {
|
||||
final List<CharSequence> installedPackages = new ArrayList<>();
|
||||
for (final String knownPackage : KNOWN_PACKAGES) {
|
||||
if (isPackageInstalled(context, knownPackage)) {
|
||||
installedPackages.add(knownPackage);
|
||||
}
|
||||
}
|
||||
return installedPackages;
|
||||
}
|
||||
|
||||
private static boolean isPackageInstalled(final Context context, final String packageName) {
|
||||
try {
|
||||
return context.getPackageManager().getApplicationInfo(packageName, 0).enabled;
|
||||
} catch (final PackageManager.NameNotFoundException e) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
public interface Callback {
|
||||
void onVoiceHelperConnection(boolean connected);
|
||||
}
|
||||
}
|
@ -2429,4 +2429,11 @@
|
||||
<string name="wake_up_time">Wake Up</string>
|
||||
<string name="pref_sleep_mode_schedule_title">Sleep Mode Schedule</string>
|
||||
<string name="pref_sleep_mode_schedule_summary">Send a reminder and enter sleep mode at bedtime. At the scheduled wake-up time, the wake-up alarm will sound.</string>
|
||||
<string name="activity_prefs_voice_helper">Voice Helper options</string>
|
||||
<string name="voice_helper_package">Voice Helper package name</string>
|
||||
<string name="voice_helper_install">Install Voice Helper</string>
|
||||
<string name="voice_helper_permissions_summary">Gadgetbridge needs permission to use the Voice Helper. Tap this button to grant them.</string>
|
||||
<string name="voice_helper_not_installed">Voice Helper is not installed</string>
|
||||
<string name="voice_helper_not_compatible">The installed Voice Helper version is not compatible with Gadgetbridge. Please update the Voice Helper and Gadgetbridge to the latest versions.</string>
|
||||
<string name="voice_helper_install_fail">Failed to open app store to install Voice Helper</string>
|
||||
</resources>
|
||||
|
@ -360,6 +360,10 @@
|
||||
android:key="pref_discovery_pairing"
|
||||
android:title="@string/activity_prefs_discovery_pairing"
|
||||
app:iconSpaceReserved="false" />
|
||||
<Preference
|
||||
android:key="pref_voice_helper"
|
||||
android:title="@string/activity_prefs_voice_helper"
|
||||
app:iconSpaceReserved="false" />
|
||||
</PreferenceCategory>
|
||||
|
||||
<PreferenceScreen
|
||||
|
34
app/src/main/res/xml/voice_helper.xml
Normal file
34
app/src/main/res/xml/voice_helper.xml
Normal file
@ -0,0 +1,34 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<androidx.preference.PreferenceScreen xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:app="http://schemas.android.com/apk/res-auto">
|
||||
<PreferenceCategory
|
||||
android:key="pref_key_header_voice_helper"
|
||||
android:title="@string/activity_prefs_voice_helper"
|
||||
app:iconSpaceReserved="false">
|
||||
|
||||
<ListPreference
|
||||
android:defaultValue="nodomain.freeyourgadget.voice"
|
||||
android:entries="@array/pref_huami2021_empty_array"
|
||||
android:entryValues="@array/pref_huami2021_empty_array"
|
||||
android:icon="@drawable/ic_voice"
|
||||
android:key="voice_helper_package"
|
||||
android:summary="%s"
|
||||
android:title="@string/voice_helper_package" />
|
||||
|
||||
<Preference
|
||||
android:icon="@drawable/ic_warning_gray"
|
||||
android:key="voice_helper_not_installed"
|
||||
android:summary="@string/voice_helper_not_installed" />
|
||||
|
||||
<Preference
|
||||
android:icon="@drawable/ic_voice"
|
||||
android:key="voice_helper_install"
|
||||
android:title="@string/voice_helper_install" />
|
||||
|
||||
<Preference
|
||||
android:icon="@drawable/ic_warning_gray"
|
||||
android:key="voice_helper_permissions"
|
||||
android:summary="@string/voice_helper_permissions_summary"
|
||||
android:title="@string/loyalty_cards_catima_permissions_title" />
|
||||
</PreferenceCategory>
|
||||
</androidx.preference.PreferenceScreen>
|
Loading…
Reference in New Issue
Block a user