mirror of
https://codeberg.org/Freeyourgadget/Gadgetbridge.git
synced 2026-07-31 07:44:24 +02:00
Zepp OS: Voice Memos
This commit is contained in:
@@ -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();
|
||||
|
||||
@@ -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"
|
||||
|
||||
+180
@@ -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);
|
||||
}
|
||||
}
|
||||
+292
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
+11
@@ -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 -> {
|
||||
|
||||
+119
@@ -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;
|
||||
}
|
||||
}
|
||||
+5
@@ -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.
|
||||
*/
|
||||
|
||||
+2
@@ -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()) {
|
||||
|
||||
+15
-4
@@ -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;
|
||||
|
||||
|
||||
+14
-13
@@ -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";
|
||||
|
||||
+1
-6
@@ -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) {
|
||||
|
||||
+1
-6
@@ -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");
|
||||
|
||||
+1
-6
@@ -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");
|
||||
|
||||
+25
-3
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
+1
-6
@@ -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);
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
|
||||
+1
-6
@@ -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);
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
|
||||
+74
-25
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
+2
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user