1
0
mirror of https://codeberg.org/Freeyourgadget/Gadgetbridge synced 2024-06-01 19:06:06 +02:00

Add Voice Helper support for Alexa

This commit is contained in:
José Rebelo 2023-09-17 22:03:41 +01:00
parent ec73b244ee
commit 608b984119
13 changed files with 651 additions and 95 deletions

View File

@ -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"

View File

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

View File

@ -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) {

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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>

View File

@ -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

View 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>