Zepp OS: Voice Memos

This commit is contained in:
José Rebelo
2025-04-05 21:06:10 +01:00
parent d202db4704
commit 1732564c1e
28 changed files with 1033 additions and 76 deletions
@@ -56,7 +56,7 @@ public class GBDaoGenerator {
public static void main(String[] args) throws Exception {
final Schema schema = new Schema(100, MAIN_PACKAGE + ".entities");
final Schema schema = new Schema(101, MAIN_PACKAGE + ".entities");
Entity userAttributes = addUserAttributes(schema);
Entity user = addUserInfo(schema, userAttributes);
@@ -181,6 +181,7 @@ public class GBDaoGenerator {
addContacts(schema, user, device);
addAppSpecificNotificationSettings(schema, device);
addCyclingSample(schema, user, device);
addAudioRecordings(schema, device);
Entity notificationFilter = addNotificationFilters(schema);
@@ -1218,6 +1219,29 @@ public class GBDaoGenerator {
contact.addToOne(device, deviceId);
}
private static void addAudioRecordings(Schema schema, Entity device) {
Entity recording = addEntity(schema, "AudioRecording");
recording.implementsSerializable();
recording.addStringProperty("recordingId").notNull().primaryKey();
Property deviceId = recording.addLongProperty("deviceId").notNull().getProperty();
Property timestamp = recording.addLongProperty("timestamp").notNull().getProperty();
// For queries by (device, timestamp)
Index indexDeviceFilename = new Index();
indexDeviceFilename.addProperty(deviceId);
indexDeviceFilename.addProperty(timestamp);
indexDeviceFilename.makeUnique();
recording.addIndex(indexDeviceFilename);
recording.addStringProperty("label");
recording.addStringProperty("path");
recording.addIntProperty("duration");
recording.addToOne(device, deviceId);
}
private static void addNotificationFilterEntry(Schema schema, Entity notificationFilterEntity) {
Entity notificatonFilterEntry = addEntity(schema, "NotificationFilterEntry");
notificatonFilterEntry.addIdProperty().autoincrement();
+4
View File
@@ -213,6 +213,10 @@
android:name=".activities.loyaltycards.LoyaltyCardsSettingsActivity"
android:label="@string/loyalty_cards"
android:parentActivityName=".activities.devicesettings.DeviceSettingsActivity" />
<activity
android:name=".activities.audiorecordings.AudioRecordingsActivity"
android:label="@string/audio_recordings"
android:parentActivityName=".activities.devicesettings.DeviceSettingsActivity" />
<activity
android:name=".devices.garmin.GarminRealtimeSettingsActivity"
android:label="@string/loading"
@@ -0,0 +1,180 @@
/* Copyright (C) 2025 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 <https://www.gnu.org/licenses/>. */
package nodomain.freeyourgadget.gadgetbridge.activities.audiorecordings;
import android.content.BroadcastReceiver;
import android.content.Context;
import android.content.Intent;
import android.content.IntentFilter;
import android.os.Bundle;
import android.view.Menu;
import android.view.MenuInflater;
import android.view.MenuItem;
import android.view.View;
import android.widget.Toast;
import androidx.annotation.NonNull;
import androidx.appcompat.widget.SearchView;
import androidx.core.view.MenuProvider;
import androidx.localbroadcastmanager.content.LocalBroadcastManager;
import androidx.recyclerview.widget.LinearLayoutManager;
import androidx.recyclerview.widget.RecyclerView;
import androidx.swiperefreshlayout.widget.SwipeRefreshLayout;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.util.List;
import nodomain.freeyourgadget.gadgetbridge.GBApplication;
import nodomain.freeyourgadget.gadgetbridge.R;
import nodomain.freeyourgadget.gadgetbridge.activities.AbstractGBActivity;
import nodomain.freeyourgadget.gadgetbridge.database.repository.AudioRecordingsRepository;
import nodomain.freeyourgadget.gadgetbridge.entities.AudioRecording;
import nodomain.freeyourgadget.gadgetbridge.impl.GBDevice;
import nodomain.freeyourgadget.gadgetbridge.model.RecordedDataTypes;
import nodomain.freeyourgadget.gadgetbridge.util.GB;
public class AudioRecordingsActivity extends AbstractGBActivity implements MenuProvider {
private static final Logger LOG = LoggerFactory.getLogger(AudioRecordingsActivity.class);
public static final String ACTION_FETCH_FINISH = "audio_recordings_fetch_finish";
private GBDevice device;
private SearchView searchView;
private SwipeRefreshLayout refreshLayout;
private List<AudioRecording> recordings;
private AudioRecordingsAdapter adapter;
private final BroadcastReceiver fetchStatusReceiver = new BroadcastReceiver() {
@Override
public void onReceive(final Context context, final Intent intent) {
final String action = intent.getAction();
if (!ACTION_FETCH_FINISH.equals(action)) {
LOG.error("Got unknown action {}", action);
return;
}
refreshLayout.setRefreshing(false);
recordings.clear();
recordings.addAll(AudioRecordingsRepository.listAll(device));
// FIXME insert only new records
adapter.notifyDataSetChanged();
}
};
@Override
protected void onCreate(final Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
device = getIntent().getParcelableExtra(GBDevice.EXTRA_DEVICE);
if (device == null) {
GB.toast(this, "Device is null", Toast.LENGTH_LONG, GB.ERROR);
finish();
return;
}
setContentView(R.layout.activity_audio_recordings);
addMenuProvider(this);
final RecyclerView audioRecordingsView = findViewById(R.id.audioRecordingsListView);
audioRecordingsView.setLayoutManager(new LinearLayoutManager(this));
recordings = AudioRecordingsRepository.listAll(device);
adapter = new AudioRecordingsAdapter(this, recordings);
audioRecordingsView.setAdapter(adapter);
searchView = findViewById(R.id.audioRecordingsListSearchView);
searchView.setIconifiedByDefault(false);
searchView.setVisibility(View.GONE);
searchView.setIconified(false);
searchView.setQuery("", false);
searchView.clearFocus();
searchView.setOnQueryTextListener(new SearchView.OnQueryTextListener() {
@Override
public boolean onQueryTextSubmit(final String query) {
return false;
}
@Override
public boolean onQueryTextChange(final String newText) {
adapter.getFilter().filter(newText);
return true;
}
});
refreshLayout = findViewById(R.id.audioRecordingsRefreshLayout);
refreshLayout.setOnRefreshListener(this::triggerRefresh);
final IntentFilter intentFilter = new IntentFilter();
intentFilter.addAction(ACTION_FETCH_FINISH);
LocalBroadcastManager.getInstance(this).registerReceiver(fetchStatusReceiver, intentFilter);
}
@Override
protected void onDestroy() {
LocalBroadcastManager.getInstance(this).unregisterReceiver(fetchStatusReceiver);
super.onDestroy();
}
@Override
public void onCreateMenu(@NonNull final Menu menu, @NonNull final MenuInflater menuInflater) {
menuInflater.inflate(R.menu.menu_audio_recording_activity, menu);
}
@Override
public boolean onMenuItemSelected(@NonNull final MenuItem menuItem) {
final int itemId = menuItem.getItemId();
if (itemId == R.id.audio_recording_search) {
searchView.setVisibility(View.VISIBLE);
searchView.requestFocus();
searchView.setIconified(true);
searchView.setIconified(false);
return true;
}
if (itemId == R.id.audio_recording_refresh) {
triggerRefresh();
return true;
}
return false;
}
private void triggerRefresh() {
if (!device.isConnected()) {
Toast.makeText(this, R.string.device_not_connected, Toast.LENGTH_LONG).show();
refreshLayout.setRefreshing(false);
return;
}
refreshLayout.setRefreshing(true);
GBApplication.deviceService(device).onFetchRecordedData(RecordedDataTypes.TYPE_AUDIO_REC);
}
@Override
public boolean onOptionsItemSelected(final MenuItem item) {
if (item.getItemId() == android.R.id.home) {
onBackPressed();
return true;
}
return super.onOptionsItemSelected(item);
}
}
@@ -0,0 +1,292 @@
/* Copyright (C) 2025 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 <https://www.gnu.org/licenses/>. */
package nodomain.freeyourgadget.gadgetbridge.activities.audiorecordings;
import android.content.Context;
import android.text.InputType;
import android.view.LayoutInflater;
import android.view.View;
import android.view.ViewGroup;
import android.widget.EditText;
import android.widget.Filter;
import android.widget.FrameLayout;
import android.widget.ImageView;
import android.widget.PopupMenu;
import android.widget.TextView;
import android.widget.Toast;
import androidx.annotation.NonNull;
import androidx.recyclerview.widget.RecyclerView;
import com.google.android.material.dialog.MaterialAlertDialogBuilder;
import org.concentus.OpusException;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.io.File;
import java.io.FileInputStream;
import java.io.FileOutputStream;
import java.io.IOException;
import java.util.ArrayList;
import java.util.Date;
import java.util.List;
import java.util.Locale;
import nodomain.freeyourgadget.gadgetbridge.R;
import nodomain.freeyourgadget.gadgetbridge.database.repository.AudioRecordingsRepository;
import nodomain.freeyourgadget.gadgetbridge.entities.AudioRecording;
import nodomain.freeyourgadget.gadgetbridge.util.AndroidUtils;
import nodomain.freeyourgadget.gadgetbridge.util.AudioUtils;
import nodomain.freeyourgadget.gadgetbridge.util.DateTimeUtils;
import nodomain.freeyourgadget.gadgetbridge.util.FileUtils;
import nodomain.freeyourgadget.gadgetbridge.util.GB;
import nodomain.freeyourgadget.gadgetbridge.util.StringUtils;
public class AudioRecordingsAdapter extends RecyclerView.Adapter<AudioRecordingsAdapter.AudioRecordingsViewHolder> {
protected static final Logger LOG = LoggerFactory.getLogger(AudioRecordingsAdapter.class);
private final List<AudioRecording> recordingsList;
private final Context mContext;
private final AudioRecordingFilter filter;
public AudioRecordingsAdapter(final Context context, final List<AudioRecording> recordings) {
mContext = context;
recordingsList = recordings;
filter = new AudioRecordingFilter(this, recordingsList);
}
@NonNull
@Override
public AudioRecordingsViewHolder onCreateViewHolder(@NonNull final ViewGroup parent, final int viewType) {
final View view = LayoutInflater.from(mContext).inflate(R.layout.item_audio_recording, parent, false);
return new AudioRecordingsViewHolder(view);
}
@Override
public void onBindViewHolder(@NonNull final AudioRecordingsViewHolder holder, final int position) {
final AudioRecording recording = recordingsList.get(position);
final String name;
if (!StringUtils.isNullOrEmpty(recording.getLabel())) {
name = recording.getLabel();
} else {
name = DateTimeUtils.formatDateTime(new Date(recording.getTimestamp()));
}
holder.itemView.setOnClickListener(v -> {
try {
final File wavFile = recordingAsWav(recording, name + ".wav");
AndroidUtils.viewFile(wavFile.getPath(), "audio/wav", mContext);
} catch (final IOException e) {
GB.toast("Failed to share file", Toast.LENGTH_LONG, GB.ERROR, e);
}
});
holder.date.setText(DateTimeUtils.formatDateTime(new Date(recording.getTimestamp())));
holder.label.setText(recording.getLabel());
//holder.seekBar.setProgress(0);
//holder.playbackPosition.setText(formatProgress(0, recording.getDuration()));
holder.menu.setOnClickListener(view -> {
final PopupMenu menu = new PopupMenu(mContext, holder.menu);
menu.inflate(R.menu.menu_audio_recording_item);
menu.setOnMenuItemClickListener(item -> {
final int itemId = item.getItemId();
if (itemId == R.id.audio_recording_item_menu_rename) {
final EditText input = new EditText(mContext);
input.setInputType(InputType.TYPE_CLASS_TEXT);
input.setText((recording.getLabel() != null) ? recording.getLabel() : "");
final FrameLayout container = new FrameLayout(mContext);
final FrameLayout.LayoutParams params = new FrameLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT);
params.leftMargin = mContext.getResources().getDimensionPixelSize(R.dimen.dialog_margin);
params.rightMargin = mContext.getResources().getDimensionPixelSize(R.dimen.dialog_margin);
input.setLayoutParams(params);
container.addView(input);
new MaterialAlertDialogBuilder(mContext)
.setView(container)
.setCancelable(true)
.setTitle(mContext.getString(R.string.activity_summary_edit_name_title))
.setPositiveButton(R.string.ok, (dialog, which) -> {
String newLabel = input.getText().toString();
if (newLabel.isEmpty()) newLabel = null;
recording.setLabel(newLabel);
AudioRecordingsRepository.insertOrReplace(null, recording);
notifyItemChanged(position);
})
.setNegativeButton(R.string.Cancel, (dialog, which) -> {
// do nothing
})
.show();
}
if (itemId == R.id.audio_recording_item_menu_share) {
try {
final File wavFile = recordingAsWav(recording, name + ".wav");
AndroidUtils.shareFile(mContext, wavFile, "audio/wav");
} catch (final IOException e) {
GB.toast("Failed to share file", Toast.LENGTH_LONG, GB.ERROR, e);
}
return true;
}
if (itemId == R.id.audio_recording_item_menu_delete) {
new MaterialAlertDialogBuilder(mContext)
.setTitle(R.string.Delete)
.setMessage(mContext.getString(R.string.music_delete_confirm_description, name))
.setIcon(R.drawable.ic_warning)
.setPositiveButton(android.R.string.yes, (dialog, whichButton) -> {
AudioRecordingsRepository.delete(recording);
recordingsList.remove(position);
notifyItemRemoved(position);
})
.setNegativeButton(android.R.string.no, null)
.show();
return true;
}
return false;
});
menu.show();
});
}
private File recordingAsWav(final AudioRecording recording, final String filename) throws IOException {
final File opusFile = new File(recording.getPath());
if (!opusFile.getName().endsWith(".opus")) {
throw new IOException("Unknown file type for " + opusFile.getName());
}
final File cacheDir = mContext.getCacheDir();
final File rawCacheDir = new File(cacheDir, "audio");
rawCacheDir.mkdir();
final File wavFile = new File(rawCacheDir, filename);
wavFile.deleteOnExit();
try (FileInputStream fin = new FileInputStream(opusFile);
FileOutputStream outputStream = new FileOutputStream(wavFile)) {
// FIXME: This should be streamed
final byte[] opusBytes = FileUtils.readAll(fin, 100 * 1024 * 1024);
final byte[] pcmBytes = AudioUtils.opusToPcm(opusBytes);
AudioUtils.writeWavHeader(pcmBytes.length, outputStream);
outputStream.write(pcmBytes);
} catch (final OpusException e) {
throw new IOException("Failed to decode opus", e);
}
return wavFile;
}
private String formatProgress(final int millis) {
final long totalSeconds = millis / 1000;
final long hours = totalSeconds / 3600;
final long minutes = (totalSeconds % 3600) / 60;
final long seconds = totalSeconds % 60;
if (hours > 0) {
return String.format(Locale.ROOT, "%d:%02d:%02d", hours, minutes, seconds);
} else {
return String.format(Locale.ROOT, "%02d:%02d", minutes, seconds);
}
}
private String formatProgress(final int posMillis, final int totalMillis) {
return String.format(
Locale.ROOT, "%s / %s",
formatProgress(posMillis),
formatProgress(totalMillis)
);
}
@Override
public int getItemCount() {
return recordingsList.size();
}
public Filter getFilter() {
return filter;
}
public static class AudioRecordingsViewHolder extends RecyclerView.ViewHolder {
final TextView date;
final TextView label;
//final ImageView playbackToggle;
final ImageView menu;
//final SeekBar seekBar;
//final TextView playbackPosition;
final View divider;
AudioRecordingsViewHolder(View itemView) {
super(itemView);
date = itemView.findViewById(R.id.recordingItemDate);
label = itemView.findViewById(R.id.recordingItemLabel);
//playbackToggle = itemView.findViewById(R.id.recordingItemPlaybackToggle);
menu = itemView.findViewById(R.id.recordingItemMenu);
//seekBar = itemView.findViewById(R.id.recordingItemSeekBar);
//playbackPosition = itemView.findViewById(R.id.recordingItemPlaybackPosition);
divider = itemView.findViewById(R.id.recordingItemDivider);
}
}
private static class AudioRecordingFilter extends Filter {
private final AudioRecordingsAdapter adapter;
private final List<AudioRecording> originalList;
private final List<AudioRecording> filteredList;
private AudioRecordingFilter(final AudioRecordingsAdapter adapter, final List<AudioRecording> originalList) {
super();
this.originalList = new ArrayList<>(originalList);
this.filteredList = new ArrayList<>();
this.adapter = adapter;
}
@Override
protected FilterResults performFiltering(final CharSequence filter) {
filteredList.clear();
final FilterResults results = new FilterResults();
if (filter == null || filter.length() == 0)
filteredList.addAll(originalList);
else {
final String filterPattern = filter.toString().toLowerCase().trim();
for (AudioRecording a : originalList) {
if (a.getLabel().toLowerCase().contains(filterPattern)) {
filteredList.add(a);
}
}
}
results.values = filteredList;
results.count = filteredList.size();
return results;
}
@Override
protected void publishResults(final CharSequence constraint, final FilterResults filterResults) {
adapter.recordingsList.clear();
adapter.recordingsList.addAll((List<AudioRecording>) filterResults.values);
adapter.notifyDataSetChanged();
}
}
}
@@ -68,6 +68,7 @@ import nodomain.freeyourgadget.gadgetbridge.activities.CalBlacklistActivity;
import nodomain.freeyourgadget.gadgetbridge.activities.ConfigureContacts;
import nodomain.freeyourgadget.gadgetbridge.activities.ConfigureWorldClocks;
import nodomain.freeyourgadget.gadgetbridge.activities.app_specific_notifications.AppSpecificNotificationSettingsActivity;
import nodomain.freeyourgadget.gadgetbridge.activities.audiorecordings.AudioRecordingsActivity;
import nodomain.freeyourgadget.gadgetbridge.activities.loyaltycards.LoyaltyCardsSettingsActivity;
import nodomain.freeyourgadget.gadgetbridge.activities.loyaltycards.LoyaltyCardsSettingsConst;
import nodomain.freeyourgadget.gadgetbridge.activities.musicmanager.MusicManagerActivity;
@@ -1347,6 +1348,16 @@ public class DeviceSpecificSettingsFragment extends AbstractPreferenceFragment i
});
}
final Preference audioRecordings = findPreference("pref_key_audio_recordings");
if (audioRecordings != null) {
audioRecordings.setOnPreferenceClickListener(preference -> {
final Intent intent = new Intent(getContext(), AudioRecordingsActivity.class);
intent.putExtra(GBDevice.EXTRA_DEVICE, getDevice());
startActivity(intent);
return true;
});
}
final Preference notificationSettings = findPreference(PREFS_PER_APP_NOTIFICATION_SETTINGS);
if (notificationSettings != null) {
notificationSettings.setOnPreferenceClickListener(preference -> {
@@ -0,0 +1,119 @@
/* Copyright (C) 2025 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 <https://www.gnu.org/licenses/>. */
package nodomain.freeyourgadget.gadgetbridge.database.repository;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.util.Collections;
import java.util.List;
import de.greenrobot.dao.query.QueryBuilder;
import nodomain.freeyourgadget.gadgetbridge.GBApplication;
import nodomain.freeyourgadget.gadgetbridge.database.DBHandler;
import nodomain.freeyourgadget.gadgetbridge.database.DBHelper;
import nodomain.freeyourgadget.gadgetbridge.entities.AudioRecording;
import nodomain.freeyourgadget.gadgetbridge.entities.AudioRecordingDao;
import nodomain.freeyourgadget.gadgetbridge.entities.DaoSession;
import nodomain.freeyourgadget.gadgetbridge.entities.Device;
import nodomain.freeyourgadget.gadgetbridge.impl.GBDevice;
public class AudioRecordingsRepository {
private static final Logger LOG = LoggerFactory.getLogger(AudioRecordingsRepository.class);
@NonNull
public static List<AudioRecording> listAll(final GBDevice gbDevice) {
try (DBHandler db = GBApplication.acquireDB()) {
final DaoSession daoSession = db.getDaoSession();
final Device dbDevice = DBHelper.findDevice(gbDevice, daoSession);
if (dbDevice != null) {
final AudioRecordingDao dao = daoSession.getAudioRecordingDao();
final Long deviceId = dbDevice.getId();
final QueryBuilder<AudioRecording> qb = dao.queryBuilder()
.where(AudioRecordingDao.Properties.DeviceId.eq(deviceId))
.orderDesc(AudioRecordingDao.Properties.Timestamp);
return qb.build().list();
}
} catch (final Exception e) {
LOG.error("Error listing audio recordings from db", e);
}
return Collections.emptyList();
}
@Nullable
public static AudioRecording getByTimestamp(final GBDevice gbDevice, final long timestamp) {
try (DBHandler db = GBApplication.acquireDB()) {
final DaoSession daoSession = db.getDaoSession();
final Device dbDevice = DBHelper.findDevice(gbDevice, daoSession);
if (dbDevice != null) {
final AudioRecordingDao dao = daoSession.getAudioRecordingDao();
final Long deviceId = dbDevice.getId();
final QueryBuilder<AudioRecording> qb = dao.queryBuilder()
.where(AudioRecordingDao.Properties.DeviceId.eq(deviceId),
AudioRecordingDao.Properties.Timestamp.eq(timestamp))
.orderDesc(AudioRecordingDao.Properties.Timestamp);
final List<AudioRecording> list = qb.build().list();
if (list.isEmpty()) {
return null;
} else if (list.size() > 1) {
// this is not possible
LOG.error("More than 1 recording with the same timestamp {}", timestamp);
}
return list.get(0);
}
} catch (final Exception e) {
LOG.error("Error listing audio recordings from db", e);
}
return null;
}
public static boolean insertOrReplace(@Nullable final GBDevice gbDevice, final AudioRecording entity) {
try (DBHandler db = GBApplication.acquireDB()) {
final DaoSession daoSession = db.getDaoSession();
if (gbDevice != null) {
final Device device = DBHelper.getDevice(gbDevice, daoSession);
entity.setDevice(device);
} else if (entity.getDevice() == null) {
// FIXME this is ugly
throw new Exception("Attempting to upsert entity without a device");
}
daoSession.insertOrReplace(entity);
} catch (final Exception e) {
LOG.error("Error inserting or replacing audio recording", e);
return false;
}
return true;
}
public static boolean delete(final AudioRecording entity) {
try (DBHandler db = GBApplication.acquireDB()) {
final DaoSession daoSession = db.getDaoSession();
daoSession.delete(entity);
} catch (final Exception e) {
LOG.error("Error deleting audio recording", e);
return false;
}
return true;
}
}
@@ -697,6 +697,11 @@ public abstract class AbstractDeviceCoordinator implements DeviceCoordinator {
return false;
}
@Override
public boolean supportsAudioRecordings(final GBDevice device) {
return false;
}
@Override
public int getContactsSlotCount(final GBDevice device) {
return 0;
@@ -700,6 +700,11 @@ public interface DeviceCoordinator {
*/
boolean supportsDisabledWorldClocks();
/**
* Indicates whether the device supports recording and syncing audio recordings.
*/
boolean supportsAudioRecordings(GBDevice device);
/**
* Indicates the maximum number of slots available for contacts in the device.
*/
@@ -57,6 +57,7 @@ import nodomain.freeyourgadget.gadgetbridge.devices.miband.MiBandPairingActivity
import nodomain.freeyourgadget.gadgetbridge.devices.miband.MiBandService;
import nodomain.freeyourgadget.gadgetbridge.devices.miband.VibrationProfile;
import nodomain.freeyourgadget.gadgetbridge.entities.AbstractActivitySample;
import nodomain.freeyourgadget.gadgetbridge.entities.AudioRecordingDao;
import nodomain.freeyourgadget.gadgetbridge.entities.BaseActivitySummaryDao;
import nodomain.freeyourgadget.gadgetbridge.entities.DaoSession;
import nodomain.freeyourgadget.gadgetbridge.entities.Device;
@@ -117,6 +118,7 @@ public abstract class HuamiCoordinator extends AbstractBLEDeviceCoordinator {
put(session.getGenericTemperatureSampleDao(), GenericTemperatureSampleDao.Properties.DeviceId);
put(session.getHuamiSleepSessionSampleDao(), HuamiSleepSessionSampleDao.Properties.DeviceId);
put(session.getBaseActivitySummaryDao(), BaseActivitySummaryDao.Properties.DeviceId);
put(session.getAudioRecordingDao(), AudioRecordingDao.Properties.DeviceId);
}};
for (final Map.Entry<AbstractDao<?, ?>, Property> e : daoMap.entrySet()) {
@@ -349,6 +349,11 @@ public abstract class ZeppOsCoordinator extends HuamiCoordinator {
return Arrays.asList(HeartRateCapability.MeasurementInterval.values());
}
@Override
public boolean supportsAudioRecordings(final GBDevice device) {
return supportsDisplayItem(device, "voice_memos") && supportsBleFileTransfer(device, "voicememo");
}
/**
* Returns a superset of all settings supported by Zepp OS Devices. Unsupported settings are removed
* by {@link ZeppOsSettingsCustomizer}.
@@ -364,6 +369,9 @@ public abstract class ZeppOsCoordinator extends HuamiCoordinator {
if (ZeppOsLoyaltyCardService.isSupported(getPrefs(device))) {
deviceSpecificSettings.addRootScreen(R.xml.devicesettings_loyalty_cards);
}
if (supportsAudioRecordings(device)) {
deviceSpecificSettings.addRootScreen(R.xml.devicesettings_audio_recordings);
}
//
// Time
@@ -607,10 +615,7 @@ public abstract class ZeppOsCoordinator extends HuamiCoordinator {
}
public boolean supportsMusicUpload(final GBDevice device) {
return supportsDisplayItem(device, "music") &&
getPrefs(device)
.getStringSet(ZeppOsFileTransferImpl.PREF_SUPPORTED_SERVICES, Collections.emptySet())
.contains("music");
return supportsDisplayItem(device, "music") && supportsBleFileTransfer(device, "music");
}
private boolean supportsConfig(final GBDevice device, final ZeppOsConfigService.ConfigArg config) {
@@ -624,6 +629,12 @@ public abstract class ZeppOsCoordinator extends HuamiCoordinator {
).contains(item);
}
private boolean supportsBleFileTransfer(final GBDevice device, final String service) {
return getPrefs(device)
.getStringSet(ZeppOsFileTransferImpl.PREF_SUPPORTED_SERVICES, Collections.emptySet())
.contains(service);
}
public static boolean experimentalFeatures(final GBDevice device) {
return getPrefs(device).getBoolean("zepp_os_experimental_features", false);
}
@@ -31,6 +31,7 @@ public class RecordedDataTypes {
public static final int TYPE_HUAMI_STATISTICS = 0x00000400;
public static final int TYPE_SLEEP = 0x00000800;
public static final int TYPE_HRV = 0x00001000;
public static final int TYPE_AUDIO_REC = 0x00002000;
public static final int TYPE_ALL = (int)0xffffffff;
@@ -86,6 +86,7 @@ import nodomain.freeyourgadget.gadgetbridge.deviceevents.GBDeviceEventUpdatePref
import nodomain.freeyourgadget.gadgetbridge.devices.huami.zeppos.ZeppOsMapsInstallHandler;
import nodomain.freeyourgadget.gadgetbridge.devices.huami.zeppos.ZeppOsMusicInstallHandler;
import nodomain.freeyourgadget.gadgetbridge.impl.GBDevice;
import nodomain.freeyourgadget.gadgetbridge.model.RecordedDataTypes;
import nodomain.freeyourgadget.gadgetbridge.service.SleepAsAndroidSender;
import nodomain.freeyourgadget.gadgetbridge.devices.huami.HuamiFWHelper;
import nodomain.freeyourgadget.gadgetbridge.devices.huami.zeppos.ZeppOsCoordinator;
@@ -163,7 +164,7 @@ import nodomain.freeyourgadget.gadgetbridge.util.FileUtils;
import nodomain.freeyourgadget.gadgetbridge.util.GB;
import nodomain.freeyourgadget.gadgetbridge.util.Prefs;
public class ZeppOsSupport extends HuamiSupport implements ZeppOsFileTransferService.Callback {
public class ZeppOsSupport extends HuamiSupport implements ZeppOsFileTransferService.DownloadCallback {
private static final Logger LOG = LoggerFactory.getLogger(ZeppOsSupport.class);
// Tracks whether realtime HR monitoring is already started, so we can just
@@ -358,6 +359,15 @@ public class ZeppOsSupport extends HuamiSupport implements ZeppOsFileTransferSer
findDeviceService.sendFindDeviceCommand(start);
}
@Override
public void onFetchRecordedData(final int dataTypes) {
if ((dataTypes & RecordedDataTypes.TYPE_AUDIO_REC) != 0 && getCoordinator().supportsAudioRecordings(getDevice())) {
voiceMemosService.requestList();
}
super.onFetchRecordedData(dataTypes);
}
@Override
public void onFindPhone(final boolean start) {
findDeviceService.onFindPhone(start);
@@ -1565,16 +1575,6 @@ public class ZeppOsSupport extends HuamiSupport implements ZeppOsFileTransferSer
handleGBDeviceEvent(batteryInfo.toDeviceEvent());
}
@Override
public void onFileUploadFinish(final boolean success) {
LOG.warn("Unexpected file upload finish: {}", success);
}
@Override
public void onFileUploadProgress(final int progress) {
LOG.warn("Unexpected file upload progress: {}", progress);
}
@Override
public void onFileDownloadFinish(final String url, final String filename, final byte[] data) {
LOG.info("File received: url={} filename={} length={}", url, filename, data.length);
@@ -1585,8 +1585,9 @@ public class ZeppOsSupport extends HuamiSupport implements ZeppOsFileTransferSer
return;
}
if (url.startsWith("voicememo://") && filename.endsWith(".opus")) {
voiceMemosService.downloadFinish();
if (url.startsWith("voicememo://")) {
voiceMemosService.onFileDownloadFinish(url, filename, data);
return;
}
final String fileDownloadsDir = "zepp-os-received-files";
@@ -41,7 +41,7 @@ import nodomain.freeyourgadget.gadgetbridge.util.GB;
* to reload the AGPS update and expiration timestamps.
*/
public class ZeppOsAgpsUpdateOperation extends AbstractBTLEOperation<ZeppOsSupport>
implements ZeppOsFileTransferService.Callback, ZeppOsAgpsService.Callback {
implements ZeppOsFileTransferService.UploadCallback, ZeppOsAgpsService.Callback {
private static final Logger LOG = LoggerFactory.getLogger(ZeppOsAgpsUpdateOperation.class);
private static final String AGPS_UPDATE_URL = "agps://upgrade";
@@ -99,11 +99,6 @@ public class ZeppOsAgpsUpdateOperation extends AbstractBTLEOperation<ZeppOsSuppo
updateProgress(progressPercent);
}
@Override
public void onFileDownloadFinish(final String url, final String filename, final byte[] data) {
LOG.warn("Received unexpected file: url={} filename={} length={}", url, filename, data.length);
}
@Override
public void onAgpsUploadStartResponse(final boolean success) {
if (!success) {
@@ -31,7 +31,7 @@ import nodomain.freeyourgadget.gadgetbridge.service.devices.miband.operations.Op
import nodomain.freeyourgadget.gadgetbridge.util.GB;
public class ZeppOsGpxRouteUploadOperation extends AbstractBTLEOperation<ZeppOsSupport>
implements ZeppOsFileTransferService.Callback {
implements ZeppOsFileTransferService.UploadCallback {
private static final Logger LOG = LoggerFactory.getLogger(ZeppOsGpxRouteUploadOperation.class);
private final ZeppOsGpxRouteFile file;
@@ -89,11 +89,6 @@ public class ZeppOsGpxRouteUploadOperation extends AbstractBTLEOperation<ZeppOsS
updateProgress(progressPercent);
}
@Override
public void onFileDownloadFinish(final String url, final String filename, final byte[] data) {
LOG.warn("Received unexpected file: url={} filename={} length={}", url, filename, data.length);
}
private void updateProgress(final int progressPercent) {
try {
final TransactionBuilder builder = performInitialized("send gpx route upload progress");
@@ -33,7 +33,7 @@ import nodomain.freeyourgadget.gadgetbridge.util.GB;
import nodomain.freeyourgadget.gadgetbridge.util.audio.AudioInfo;
public class ZeppOsMusicUploadOperation extends AbstractBTLEOperation<ZeppOsSupport>
implements ZeppOsFileTransferService.Callback {
implements ZeppOsFileTransferService.UploadCallback {
private static final Logger LOG = LoggerFactory.getLogger(ZeppOsMusicUploadOperation.class);
private final AudioInfo audioInfo;
@@ -97,11 +97,6 @@ public class ZeppOsMusicUploadOperation extends AbstractBTLEOperation<ZeppOsSupp
updateProgress(progressPercent);
}
@Override
public void onFileDownloadFinish(final String url, final String filename, final byte[] data) {
LOG.warn("Received unexpected file: url={} filename={} length={}", url, filename, data.length);
}
private void updateProgress(final int progressPercent) {
try {
final TransactionBuilder builder = performInitialized("send music upload progress");
@@ -101,10 +101,32 @@ public class ZeppOsFileTransferService extends AbstractZeppOsService {
}
public interface Callback {
void onFileUploadFinish(boolean success);
void onFileUploadFinish(final boolean success);
void onFileUploadProgress(int progress);
void onFileUploadProgress(final int progress);
void onFileDownloadFinish(String url, String filename, byte[] data);
void onFileDownloadFinish(final String url, final String filename, final byte[] data);
}
public interface UploadCallback extends Callback {
void onFileUploadFinish(final boolean success);
void onFileUploadProgress(final int progress);
default void onFileDownloadFinish(final String url, final String filename, final byte[] data) {
LOG.error("Received unexpected file on upload callback for {}: url={} filename={} length={}", getClass(), url, filename, data.length);
}
}
public interface DownloadCallback extends Callback {
default void onFileUploadFinish(final boolean success) {
LOG.error("Received unexpected upload finish on download callback for {}: success={}", getClass(), success);
}
default void onFileUploadProgress(final int progress) {
LOG.error("Received unexpected upload progress on download callback for {}: progress={}", getClass(), progress);
}
void onFileDownloadFinish(final String url, final String filename, final byte[] data);
}
}
@@ -194,7 +194,7 @@ public class ZeppOsHttpService extends AbstractZeppOsService {
filename,
rawBytes,
false,
new ZeppOsFileTransferService.Callback() {
new ZeppOsFileTransferService.UploadCallback() {
@Override
public void onFileUploadFinish(final boolean success) {
LOG.info("Finished sending '{}' to http request id '{}', success={}", filename, requestId, success);
@@ -211,11 +211,6 @@ public class ZeppOsHttpService extends AbstractZeppOsService {
downloadCallback.onFileDownloadProgress(progress);
}
}
@Override
public void onFileDownloadFinish(final String url, final String filename, final byte[] data) {
LOG.warn("Receiver unexpected file: url={} filename={} length={}", url, filename, data.length);
}
}
);
@@ -562,7 +562,7 @@ public class ZeppOsNotificationService extends AbstractZeppOsService {
filename,
bytes,
true,
new ZeppOsFileTransferService.Callback() {
new ZeppOsFileTransferService.UploadCallback() {
@Override
public void onFileUploadFinish(final boolean success) {
LOG.info("Finished sending '{}' to '{}', success={}", filename, url, success);
@@ -573,11 +573,6 @@ public class ZeppOsNotificationService extends AbstractZeppOsService {
public void onFileUploadProgress(final int progress) {
LOG.trace("File send progress: {}", progress);
}
@Override
public void onFileDownloadFinish(final String url, final String filename, final byte[] data) {
LOG.warn("Receiver unexpected file: url={} filename={} length={}", url, filename, data.length);
}
}
);
@@ -16,24 +16,36 @@
along with this program. If not, see <https://www.gnu.org/licenses/>. */
package nodomain.freeyourgadget.gadgetbridge.service.devices.huami.zeppos.services;
import android.content.Intent;
import android.os.Handler;
import androidx.localbroadcastmanager.content.LocalBroadcastManager;
import org.apache.commons.lang3.ArrayUtils;
import org.concentus.OpusDecoder;
import org.concentus.OpusException;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.io.File;
import java.io.FileOutputStream;
import java.io.IOException;
import java.nio.ByteBuffer;
import java.nio.ByteOrder;
import java.util.Date;
import java.util.HashMap;
import java.util.LinkedList;
import java.util.Map;
import java.util.Objects;
import java.util.Queue;
import java.util.UUID;
import nodomain.freeyourgadget.gadgetbridge.BuildConfig;
import nodomain.freeyourgadget.gadgetbridge.activities.audiorecordings.AudioRecordingsActivity;
import nodomain.freeyourgadget.gadgetbridge.database.repository.AudioRecordingsRepository;
import nodomain.freeyourgadget.gadgetbridge.entities.AudioRecording;
import nodomain.freeyourgadget.gadgetbridge.service.devices.huami.zeppos.AbstractZeppOsService;
import nodomain.freeyourgadget.gadgetbridge.service.devices.huami.zeppos.ZeppOsSupport;
import nodomain.freeyourgadget.gadgetbridge.util.DateTimeUtils;
import nodomain.freeyourgadget.gadgetbridge.util.FileUtils;
import nodomain.freeyourgadget.gadgetbridge.util.StringUtils;
public class ZeppOsVoiceMemosService extends AbstractZeppOsService {
@@ -48,6 +60,7 @@ public class ZeppOsVoiceMemosService extends AbstractZeppOsService {
private static final byte CMD_DOWNLOAD_FINISH_REQUEST = 0x0a;
private static final byte CMD_DOWNLOAD_FINISH_ACK = 0x09;
private final Map<String, AudioRecording> downloadingRecordings = new HashMap<>();
private final Queue<String> downloadQueue = new LinkedList<>();
private boolean downloading = false;
private final Handler handler = new Handler();
@@ -76,19 +89,37 @@ public class ZeppOsVoiceMemosService extends AbstractZeppOsService {
LOG.info("Got list with {} voice memos", count);
for (int i = 0; i < count; i++) {
final String filename = StringUtils.untilNullTerminator(buf);
final String filename = Objects.requireNonNull(StringUtils.untilNullTerminator(buf));
final int size = buf.getInt();
final int duration = buf.getInt();
final long timestamp = buf.getLong();
final AudioRecording existingRecording = AudioRecordingsRepository.getByTimestamp(getSupport().getDevice(), timestamp);
LOG.debug(
"Voice memo: filename={}, size={}b, duration={}ms, timestamp={}",
filename, size, duration, DateTimeUtils.formatIso8601(new Date(timestamp))
);
if (existingRecording != null) {
LOG.debug("Ignoring known local recording {}", filename);
continue;
}
final AudioRecording audioRecording = new AudioRecording();
audioRecording.setRecordingId(UUID.randomUUID().toString());
audioRecording.setLabel(filename.replace(".opus", ""));
audioRecording.setTimestamp(timestamp);
audioRecording.setDuration(duration);
downloadingRecordings.put(filename, audioRecording);
downloadStart(Objects.requireNonNull(filename));
}
if (!downloading) {
broadcastDownloadFinished();
}
return;
case CMD_DOWNLOAD_START_ACK:
LOG.info("Download start ACK, status = {}", payload[1]);
@@ -115,7 +146,40 @@ public class ZeppOsVoiceMemosService extends AbstractZeppOsService {
}
}
public void downloadFinish() {
public void onFileDownloadFinish(final String url, final String filename, final byte[] data) {
final AudioRecording audioRecording = downloadingRecordings.get(filename);
if (audioRecording == null) {
LOG.error("Received file {} for unknown audio recording", filename);
downloadNext();
return;
}
final File targetFile;
try {
final File exportDirectory = getCoordinator().getWritableExportDirectory(getSupport().getDevice());
final File voiceMemosDirectory = new File(exportDirectory, "voicememo");
//noinspection ResultOfMethodCallIgnored
voiceMemosDirectory.mkdirs();
final String validFilename = FileUtils.makeValidFileName(filename);
targetFile = new File(voiceMemosDirectory, validFilename);
} catch (final IOException e) {
LOG.error("Failed create folder to save voice memo", e);
downloadNext();
return;
}
try (FileOutputStream outputStream = new FileOutputStream(targetFile)) {
outputStream.write(data);
} catch (final IOException e) {
LOG.error("Failed to save voice memo bytes", e);
downloadNext();
return;
}
audioRecording.setPath(targetFile.getPath());
AudioRecordingsRepository.insertOrReplace(getSupport().getDevice(), audioRecording);
downloadNext();
}
@@ -140,29 +204,14 @@ public class ZeppOsVoiceMemosService extends AbstractZeppOsService {
LOG.debug("Voice memo downloads finished");
downloading = false;
write("voice memo download finish", CMD_DOWNLOAD_FINISH_REQUEST);
broadcastDownloadFinished();
}
}
private void decode(final byte[] opusBytes) throws OpusException {
final int SAMPLE_RATE = 16000;
final int NUM_CHANNELS = 1;
final int FRAME_SIZE = 320;
final OpusDecoder opusDecoder = new OpusDecoder(SAMPLE_RATE, NUM_CHANNELS);
final ByteBuffer buf = ByteBuffer.wrap(opusBytes).order(ByteOrder.BIG_ENDIAN);
final byte[] pcm = new byte[FRAME_SIZE * 2];
while (buf.hasRemaining()) {
final int len = buf.getInt();
final int unk = buf.getInt();
final byte[] arr = new byte[len];
buf.get(arr);
int decode = opusDecoder.decode(arr, 0, arr.length, pcm, 0, FRAME_SIZE, false);
// ffmpeg -f s16le -ar 16k -ac 1 -i memo.pcm memo.wav
// outputStream.write(pcm, 0, FRAME_SIZE * 2);
}
private void broadcastDownloadFinished() {
final Intent intent = new Intent(AudioRecordingsActivity.ACTION_FETCH_FINISH);
intent.setPackage(BuildConfig.APPLICATION_ID);
LocalBroadcastManager.getInstance(getContext()).sendBroadcast(intent);
}
}
@@ -110,6 +110,8 @@ public abstract class ZeppOsFileTransferImpl {
mCompressedChunkSize = buf.getInt();
final int numServices = buf.getShort();
for (int i = 0; i < numServices; i++) {
// gtr 4: terminal agps notification jsapp sticky_notification nfc sport httpproxy
// active 2: terminal agps notification jsapp sticky_notification nfc sport httpproxy readiness voicememo
supportedServices.add(StringUtils.untilNullTerminator(buf));
}
@@ -0,0 +1,81 @@
/* Copyright (C) 2025 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 <https://www.gnu.org/licenses/>. */
package nodomain.freeyourgadget.gadgetbridge.util;
import org.concentus.OpusDecoder;
import org.concentus.OpusException;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.io.OutputStream;
import java.nio.ByteBuffer;
import java.nio.ByteOrder;
public class AudioUtils {
public static byte[] opusToPcm(final byte[] opusBytes) throws OpusException {
final int SAMPLE_RATE = 16000;
final int NUM_CHANNELS = 1;
final int FRAME_SIZE = 320;
final OpusDecoder opusDecoder = new OpusDecoder(SAMPLE_RATE, NUM_CHANNELS);
final ByteBuffer buf = ByteBuffer.wrap(opusBytes).order(ByteOrder.BIG_ENDIAN);
final byte[] pcm = new byte[FRAME_SIZE * 2];
final ByteArrayOutputStream baos = new ByteArrayOutputStream();
while (buf.hasRemaining()) {
final int len = buf.getInt();
final int unk = buf.getInt();
final byte[] arr = new byte[len];
buf.get(arr);
final int decoded = opusDecoder.decode(arr, 0, arr.length, pcm, 0, FRAME_SIZE, false);
// ffmpeg -f s16le -ar 16k -ac 1 -i memo.pcm memo.wav
baos.write(pcm, 0, decoded * 2 /* 16-bit */);
}
return baos.toByteArray();
}
public static void writeWavHeader(final int pcmLength, final OutputStream out) throws IOException {
final int totalLength = pcmLength + 36;
final ByteBuffer buf = ByteBuffer.allocate(44).order(ByteOrder.LITTLE_ENDIAN);
final int sampleRate = 16000;
final int channels = 1;
final int bitsPerSample = 16;
buf.put("RIFF".getBytes());
buf.putInt(totalLength);
buf.put("WAVE".getBytes()); // file type header
buf.put("fmt ".getBytes());
buf.putInt(16); // length of format data
buf.putShort((short) 1); // 1 = pcm
buf.putShort((short) channels); // 1 channel
buf.putInt(sampleRate); // sample rate
buf.putInt((sampleRate * bitsPerSample * channels) / 8);
buf.putShort((short) ((bitsPerSample * channels) / 8)); // 1 - 8 bit mono, 2 - 8 bit stereo/16 bit mono, 4 - 16 bit stereo
buf.putShort((short) bitsPerSample);
buf.put("data".getBytes());
buf.putInt(pcmLength);
out.write(buf.array());
}
}
@@ -0,0 +1,33 @@
<RelativeLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent"
tools:context=".activities.audiorecordings.AudioRecordingsActivity">
<androidx.appcompat.widget.SearchView
android:id="@+id/audioRecordingsListSearchView"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_alignParentTop="true"
android:paddingStart="8dp"
android:paddingEnd="8dp" />
<androidx.swiperefreshlayout.widget.SwipeRefreshLayout
android:id="@+id/audioRecordingsRefreshLayout"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:layout_below="@id/audioRecordingsListSearchView"
tools:context=".activities.audiorecordings.AudioRecordingsActivity">
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/audioRecordingsListView"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:layout_centerHorizontal="true"
android:divider="@null"
android:scrollbarSize="5dp"
android:scrollbars="vertical" />
</androidx.swiperefreshlayout.widget.SwipeRefreshLayout>
</RelativeLayout>
@@ -0,0 +1,101 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:padding="16dp">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:gravity="center_vertical"
android:orientation="horizontal">
<LinearLayout
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:orientation="vertical">
<TextView
android:id="@+id/recordingItemDate"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="2025-03-31 22:08:00"
android:textSize="14sp"
tools:ignore="HardcodedText" />
<TextView
android:id="@+id/recordingItemLabel"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="4dp"
android:text="Test recording"
android:textSize="16sp"
tools:ignore="HardcodedText" />
</LinearLayout>
<!-- TODO: Local playback not yet implemented
<ImageView
android:id="@+id/recordingItemPlaybackToggle"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:background="?android:attr/selectableItemBackground"
android:clickable="true"
android:contentDescription="@string/pref_media_playpause"
android:focusable="true"
android:padding="12dp"
android:src="@drawable/ic_play" />
-->
<ImageView
android:id="@+id/recordingItemMenu"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:background="?android:attr/selectableItemBackground"
android:clickable="true"
android:contentDescription="@string/menuitem_menu"
android:focusable="true"
android:padding="12dp"
android:src="@drawable/ic_menu" />
</LinearLayout>
<!-- TODO: Local playback not yet implemented
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:gravity="center_vertical"
android:orientation="horizontal">
<SeekBar
android:id="@+id/recordingItemSeekBar"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1" />
<TextView
android:id="@+id/recordingItemPlaybackPosition"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="00:00 / 01:10"
android:textSize="14sp"
tools:ignore="HardcodedText" />
</LinearLayout>
-->
</LinearLayout>
<View
android:id="@+id/recordingItemDivider"
android:layout_width="match_parent"
android:layout_height="1dp"
android:layout_marginTop="0dp"
android:background="?attr/row_separator" />
</LinearLayout>
@@ -0,0 +1,18 @@
<menu xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
tools:context="nodomain.freeyourgadget.gadgetbridge.activities.audiorecordings.AudioRecordingsActivity">
<item
android:id="@+id/audio_recording_search"
android:icon="@drawable/ic_search"
android:title="@string/search"
app:iconTint="?attr/actionmenu_icon_color"
app:showAsAction="always" />
<item
android:id="@+id/audio_recording_refresh"
android:icon="@drawable/ic_refresh"
android:title="@string/controlcenter_fetch_activity_data"
app:iconTint="?attr/actionmenu_icon_color"
app:showAsAction="ifRoom" />
</menu>
@@ -0,0 +1,12 @@
<?xml version="1.0" encoding="utf-8"?>
<menu xmlns:android="http://schemas.android.com/apk/res/android">
<item
android:id="@+id/audio_recording_item_menu_rename"
android:title="@string/activity_summary_edit_name_title" />
<item
android:id="@+id/audio_recording_item_menu_share"
android:title="@string/share" />
<item
android:id="@+id/audio_recording_item_menu_delete"
android:title="@string/Delete" />
</menu>
+1
View File
@@ -62,6 +62,7 @@
<string name="controlcenter_snackbar_requested_screenshot">Taking a screenshot of the device</string>
<string name="controlcenter_calibrate_device">Calibrate Device</string>
<string name="controlcenter_get_heartrate_measurement">Get heart rate measurement</string>
<string name="audio_recordings">Audio Recordings</string>
<string name="accuracy">Accuracy</string>
<string name="balanced">Balanced</string>
@@ -0,0 +1,7 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.preference.PreferenceScreen xmlns:android="http://schemas.android.com/apk/res/android">
<Preference
android:icon="@drawable/ic_microphone"
android:key="pref_key_audio_recordings"
android:title="@string/audio_recordings" />
</androidx.preference.PreferenceScreen>
+1
View File
@@ -5,4 +5,5 @@
<files-path name="gpx" path="./" />
<cache-path name="raw" path="raw/" />
<cache-path name="gpx" path="gpx/" />
<cache-path name="audio" path="audio/" />
</paths>