diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index bcb941a..8b4de8c 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -39,6 +39,7 @@
+
setStartBeat();
}
+
+void PlaybackEngine::playMusic(int fd) {
+ //if(mPlayer) mPlayer->playMusic();
+ // TODO: fd is opened; dispose of fd when stopping or being discarded ...
+ LOGI("PlaybackEngine::playMusic(fd=%d)", fd);
+ close(fd); // for now, nothing is implemented. we just close it again.
+ // we will use mp3file_open_fd() later.
+}
diff --git a/app/src/main/cpp/PlaybackEngine.h b/app/src/main/cpp/PlaybackEngine.h
index b6ad43b..4520add 100644
--- a/app/src/main/cpp/PlaybackEngine.h
+++ b/app/src/main/cpp/PlaybackEngine.h
@@ -15,6 +15,7 @@ public:
virtual ~PlaybackEngine();
/** Play a beat sound. */
virtual void playBeat();
+ void playMusic(int fd);
private:
MixingPlayer *mPlayer;
std::string mFilesDir;
diff --git a/app/src/main/cpp/jni_lockstep.cpp b/app/src/main/cpp/jni_lockstep.cpp
index 818e57f..04085db 100644
--- a/app/src/main/cpp/jni_lockstep.cpp
+++ b/app/src/main/cpp/jni_lockstep.cpp
@@ -40,4 +40,13 @@ Java_at_lockstep_pb_PlaybackEngine_native_1setDefaultStreamValues(JNIEnv *env,
oboe::DefaultStreamValues::FramesPerBurst = (int32_t) framesPerBurst;
}
+JNIEXPORT void JNICALL
+Java_at_lockstep_pb_PlaybackEngine_native_1playMusic(JNIEnv *env,
+ jclass type,
+ jlong engineHandle,
+ jint fd) {
+ auto engine = reinterpret_cast(engineHandle);
+ engine->playMusic(fd);
+}
+
} // extern "C"
diff --git a/app/src/main/cpp/mp3file.cpp b/app/src/main/cpp/mp3file.cpp
index 30395bd..f5c16bb 100644
--- a/app/src/main/cpp/mp3file.cpp
+++ b/app/src/main/cpp/mp3file.cpp
@@ -5,6 +5,7 @@
#define LOG_TAG "mp3file"
#include "mp3file.h"
+#include
#include
#include
#include "logging.h"
@@ -25,6 +26,7 @@ void mp3file_delete(MP3File *mp3file) {
free(mp3file->buffer);
mp3file->buffer = 0;
}
+ if(mp3file->android_fd) close(mp3file->android_fd);
free(mp3file);
}
@@ -93,3 +95,70 @@ on_error:
#undef handleError
}
+
+MP3File* mp3file_open_fd(int fd, int forceEncoding) {
+ const char *errorText = "";
+#define handleError(text) \
+ do { \
+ errorText = text; \
+ goto on_error; \
+ } while(0)
+
+ int err = MPG123_OK;
+ mpg123_handle *mh = mpg123_new(NULL, &err);
+ if(err != MPG123_OK || mh == NULL) {
+ LOGE("mpg123_new() failed: %s", mpg123_plain_strerror(err));
+ return NULL;
+ }
+
+ MP3File* mp3 = mp3file_init(mh);
+ if(mp3 == NULL) handleError("malloc() failed");
+ err = mpg123_open_fd(mh, fd);
+ if(err != MPG123_OK) handleError("mpg123_open()");
+
+ int encoding;
+ err = mpg123_getformat(mh, &mp3->rate, &mp3->channels, &encoding);
+ if(err != MPG123_OK) handleError("mpg123_getformat()");
+ if(encoding != MPG123_ENC_SIGNED_16) handleError("unknown file encoding");
+
+ if(forceEncoding != 0) {
+ encoding = forceEncoding;
+ }
+
+ // Ensure that this output format will not change
+ // (it could, when we allow it).
+ mpg123_format_none(mh);
+ err = mpg123_format(mh, mp3->rate, mp3->channels, encoding);
+ if(err != MPG123_OK) handleError("could not set mpg123_format()");
+
+ mp3->buffer_size = mpg123_outblock(mh);
+ mp3->buffer = (unsigned char*) malloc(mp3->buffer_size);
+ if(mp3->buffer == NULL) handleError("malloc() failed");
+
+ mp3->num_samples = mpg123_length(mh);
+ mp3->samples_per_frame = mpg123_spf(mh);
+ mp3->secs_per_frame = mpg123_tpf(mh);
+
+ if (mp3->num_samples == MPG123_ERR || mp3->samples_per_frame < 0)
+ mp3->num_frames = 0;
+ else
+ mp3->num_frames = mp3->num_samples / mp3->samples_per_frame;
+
+ if (mp3->num_samples == MPG123_ERR || mp3->samples_per_frame < 0 || mp3->secs_per_frame < 0)
+ mp3->duration = 0;
+ else
+ mp3->duration = mp3->num_samples / mp3->samples_per_frame * mp3->secs_per_frame;
+
+ mp3->offset = 0;
+ mp3->remaining_samples = (int) mp3->num_samples;
+ LOGV("channels: %d rate: %ld num_samples: %ld", mp3->channels, mp3->rate, mp3->num_samples);
+ mp3->android_fd = fd;
+ return mp3;
+
+on_error:
+ LOGE("%s, err = %s", errorText, mpg123_plain_strerror(err));
+ mp3file_delete(mp3);
+ return NULL;
+
+#undef handleError
+}
diff --git a/app/src/main/cpp/mp3file.h b/app/src/main/cpp/mp3file.h
index f742631..03e4419 100644
--- a/app/src/main/cpp/mp3file.h
+++ b/app/src/main/cpp/mp3file.h
@@ -10,6 +10,7 @@
struct MP3File
{
mpg123_handle* handle;
+ int android_fd;
int channels;
long rate;
long num_samples;
@@ -26,5 +27,6 @@ struct MP3File
MP3File* mp3file_init(mpg123_handle *handle);
void mp3file_delete(MP3File *mp3file);
MP3File* mp3file_open(const char *filename, int forceEncoding = 0);
+MP3File* mp3file_open_fd(int fd, int forceEncoding = 0);
#endif //SAMPLES_MP3FILE_H
diff --git a/app/src/main/java/at/lockstep/app/LstForegroundService.java b/app/src/main/java/at/lockstep/app/LstForegroundService.java
index aed4809..73b24f6 100644
--- a/app/src/main/java/at/lockstep/app/LstForegroundService.java
+++ b/app/src/main/java/at/lockstep/app/LstForegroundService.java
@@ -10,13 +10,19 @@ import android.hardware.Sensor;
import android.hardware.SensorEvent;
import android.hardware.SensorEventListener;
import android.hardware.SensorManager;
+import android.net.Uri;
import android.os.Build;
import android.os.IBinder;
+import android.os.ParcelFileDescriptor;
import android.os.PowerManager;
+import android.widget.Toast;
import androidx.annotation.Nullable;
import androidx.core.app.NotificationCompat;
+import java.io.FileNotFoundException;
+import java.io.IOException;
+
import at.lockstep.filter.StepDetector;
import at.lockstep.pb.PlaybackEngine;
import at.lockstep.R;
@@ -36,9 +42,10 @@ public class LstForegroundService extends Service implements SensorEventListener
private StepDetector stepDetector;
- public static Intent startIntent(Context context) {
+ public static Intent startIntent(Context context, String contentUri) {
Intent intent = new Intent(context, LstForegroundService.class);
intent.setAction(ACTION_START);
+ intent.putExtra("content_uri", contentUri);
return intent;
}
@@ -78,6 +85,14 @@ public class LstForegroundService extends Service implements SensorEventListener
if (intent != null) {
String action = intent.getAction();
if (ACTION_START.equals(action)) {
+ String contentUri = intent.getStringExtra("content_uri");
+ try {
+ PlaybackEngine.playMusic(uriToFd(contentUri));
+ } catch (IOException e) {
+ // TODO proper error handling
+ Toast.makeText(this, "Could not open music file contentUri", Toast.LENGTH_LONG).show();
+ throw new RuntimeException(e);
+ }
startCollection();
} else if (ACTION_STOP.equals(action)) {
stopCollectionAndSelf();
@@ -86,6 +101,26 @@ public class LstForegroundService extends Service implements SensorEventListener
return START_STICKY;
}
+ /** caller is responsible for close()-ing returned file descriptor! */
+ private int uriToFd(String persistedUriString) throws IOException {
+ Uri uri = Uri.parse(persistedUriString);
+
+ ParcelFileDescriptor pfd =
+ getContentResolver().openFileDescriptor(uri, "r");
+
+ if (pfd == null) {
+ throw new IOException("openFileDescriptor() returned null for URI: " + uri);
+ }
+
+ int fd = -1;
+ try {
+ fd = pfd.detachFd(); // Native side must close(fd)
+ } finally {
+ pfd.close(); // Safe after detach; Java wrapper no longer owns the fd
+ }
+ return fd;
+ }
+
private void startCollection() {
if (isCollecting) {
return;
@@ -136,8 +171,12 @@ public class LstForegroundService extends Service implements SensorEventListener
// TODO: check threading to see if these run in separate threads - if so, deleting PlaybackEngine will leave a dangling pointer in StepDetector.
// 2026-03-04 01:26:11.741 12507-12507 libc at.lockstep A Fatal signal 11 (SIGSEGV), code 2 (SEGV_ACCERR), fault addr 0xb4000071d3a79000 in tid 12507 (at.lockstep), pid 12507 (at.lockstep)
- stepDetector.close();
- PlaybackEngine.delete();
+
+ if(stepDetector != null) {
+ stepDetector.close();
+ PlaybackEngine.delete();
+ stepDetector = null;
+ }
super.onDestroy();
}
diff --git a/app/src/main/java/at/lockstep/app/MainActivity.java b/app/src/main/java/at/lockstep/app/MainActivity.java
index 7cc2307..7fcd77b 100644
--- a/app/src/main/java/at/lockstep/app/MainActivity.java
+++ b/app/src/main/java/at/lockstep/app/MainActivity.java
@@ -12,6 +12,7 @@ import androidx.core.content.ContextCompat;
import at.lockstep.R;
import at.lockstep.pb.PlaybackEngine;
+import at.lockstep.saf.SafPickerActivity;
import at.lockstep.ui.SongPickerActivity;
import android.widget.Toast;
@@ -22,6 +23,8 @@ public class MainActivity extends AppCompatActivity {
private Button btnPickSong;
private final ActivityResultLauncher launcher;
+ private String contentUri;
+
public MainActivity() {
launcher = registerForActivityResult(
new ActivityResultContracts.StartActivityForResult(),
@@ -31,6 +34,7 @@ public class MainActivity extends AppCompatActivity {
if (data != null) {
String contentUri = data.getStringExtra("content_uri");
Toast.makeText(this, "Item clicked: " + contentUri, Toast.LENGTH_LONG).show();
+ this.contentUri = contentUri;
}
}
}
@@ -51,7 +55,7 @@ public class MainActivity extends AppCompatActivity {
btnStart.setOnClickListener(v ->
ContextCompat.startForegroundService(
MainActivity.this,
- LstForegroundService.startIntent(MainActivity.this)
+ LstForegroundService.startIntent(MainActivity.this, contentUri)
)
);
@@ -72,7 +76,7 @@ public class MainActivity extends AppCompatActivity {
});
btnPickSong.setOnClickListener(v -> {
- Intent intent = new Intent(this, SongPickerActivity.class);
+ Intent intent = new Intent(this, SafPickerActivity.class);
launcher.launch(intent);
});
}
diff --git a/app/src/main/java/at/lockstep/pb/PlaybackEngine.java b/app/src/main/java/at/lockstep/pb/PlaybackEngine.java
index 818e914..4c73868 100644
--- a/app/src/main/java/at/lockstep/pb/PlaybackEngine.java
+++ b/app/src/main/java/at/lockstep/pb/PlaybackEngine.java
@@ -16,7 +16,7 @@ public class PlaybackEngine {
System.loadLibrary("lockstep-native");
// mpg123_init() must be called before createEngine()
int ok = native_mpg123_init();
- if(ok != MPG123_OK)
+ if (ok != MPG123_OK)
throw new IllegalStateException("mpg123_init() failed");
}
@@ -28,7 +28,7 @@ public class PlaybackEngine {
*/
public static boolean create(Context context, int resid) {
try {
- if(!mFilesystemInitialized) {
+ if (!mFilesystemInitialized) {
AudioResources.copyRawTracksToFilesystem(context);
mFilesystemInitialized = true;
}
@@ -41,6 +41,11 @@ public class PlaybackEngine {
setDefaultStreamValues(context);
Log.i("PlaybackEngine", "Hello PlaybackEngine");
mEngineHandle = native_createEngine(context.getFilesDir().toString(), resid);
+
+ // david:
+ // int fd = ParcelFileDescriptor.detachFd()
+ // -> C++ code (or upon failure, it is *NOT* closed)
+ // -> except, ParcelFileDescriptor.adoptFd(fd).close()
}
return (mEngineHandle != 0);
}
@@ -50,7 +55,7 @@ public class PlaybackEngine {
// * re-visit sampling rate and channel count.
// In C++ code, MixingPlayer currently forces both to 48000 and 2 respectively,
// regardless of what Android says would be optimal.
- if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.JELLY_BEAN_MR1){
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.JELLY_BEAN_MR1) {
AudioManager myAudioMgr = (AudioManager) context.getSystemService(Context.AUDIO_SERVICE);
String sampleRateStr = myAudioMgr.getProperty(AudioManager.PROPERTY_OUTPUT_SAMPLE_RATE);
int defaultSampleRate = Integer.parseInt(sampleRateStr);
@@ -62,7 +67,7 @@ public class PlaybackEngine {
}
public static void delete() {
- if (mEngineHandle != 0){
+ if (mEngineHandle != 0) {
native_deleteEngine(mEngineHandle);
}
mEngineHandle = 0;
@@ -71,9 +76,16 @@ public class PlaybackEngine {
return mEngineHandle;
}
+ public static void playMusic(int fd) {
+ if (mEngineHandle != 0) {
+ native_playMusic(mEngineHandle, fd);
+ }
+ }
+
private static native long native_createEngine(String filesDir, int resid);
private static native void native_deleteEngine(long engineHandle);
private static native void native_setDefaultStreamValues(int sampleRate, int framesPerBurst);
private static native int native_mpg123_init();
+ private static native void native_playMusic(long engineHandle, int fd);
}
diff --git a/app/src/main/java/at/lockstep/saf/SafPickerActivity.java b/app/src/main/java/at/lockstep/saf/SafPickerActivity.java
new file mode 100644
index 0000000..5841e30
--- /dev/null
+++ b/app/src/main/java/at/lockstep/saf/SafPickerActivity.java
@@ -0,0 +1,138 @@
+package at.lockstep.saf;
+
+import android.annotation.SuppressLint;
+import android.app.Activity;
+import android.content.ContentResolver;
+import android.content.Intent;
+import android.net.Uri;
+import android.os.Bundle;
+import android.util.Log;
+
+import androidx.activity.result.ActivityResult;
+import androidx.activity.result.ActivityResultCallback;
+import androidx.activity.result.ActivityResultLauncher;
+import androidx.activity.result.contract.ActivityResultContracts;
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+import androidx.appcompat.app.AppCompatActivity;
+
+import java.io.ByteArrayOutputStream;
+import java.io.IOException;
+import java.io.InputStream;
+import androidx.appcompat.app.AppCompatActivity;
+
+public class SafPickerActivity extends AppCompatActivity {
+
+ private static final String TAG = "SafExample";
+ private static final int REQ_OPEN_DOCUMENT = 1001;
+
+ @Override
+ protected void onCreate(@Nullable Bundle savedInstanceState) {
+ super.onCreate(savedInstanceState);
+ }
+
+ @Override
+ protected void onResume() {
+ super.onResume();
+ pickDocumentForPersistentReadAccess();
+ }
+
+ /**
+ * Method 1:
+ * Opens the SAF picker so the user can choose a file.
+ *
+ * You can call this from a button click, menu item, etc.
+ */
+ public void pickDocumentForPersistentReadAccess() {
+ Intent intent = new Intent(Intent.ACTION_OPEN_DOCUMENT);
+ intent.addCategory(Intent.CATEGORY_OPENABLE);
+
+ // Use "*/*" for any file, or narrow it, e.g. "audio/*", "application/pdf", etc.
+ //intent.setType("audio/*");
+ intent.setType("audio/mpeg");
+
+ // Optional: allow only local files
+ // intent.putExtra(Intent.EXTRA_LOCAL_ONLY, true);
+
+ startActivityForResult(intent, REQ_OPEN_DOCUMENT);
+/*
+ ActivityResultLauncher startActivityIntent = registerForActivityResult(
+ new ActivityResultContracts.StartActivityForResult(),
+ new ActivityResultCallback() {
+ @Override
+ public void onActivityResult(ActivityResult result) {
+ // Add same code that you want to add in onActivityResult method
+ }
+ });*/
+ }
+
+ /**
+ * Method 2:
+ * Takes a previously persisted URI string and reads the file contents as bytes.
+ *
+ * Returns the full contents in memory. For large files, stream instead.
+ */
+ public byte[] readPersistedUriBytes(@NonNull String persistedUriString) throws IOException {
+ Uri uri = Uri.parse(persistedUriString);
+ ContentResolver resolver = getContentResolver();
+
+ try (InputStream in = resolver.openInputStream(uri)) {
+ if (in == null) {
+ throw new IOException("openInputStream() returned null for URI: " + uri);
+ }
+
+ ByteArrayOutputStream out = new ByteArrayOutputStream();
+ byte[] buffer = new byte[8192];
+ int n;
+
+ while ((n = in.read(buffer)) != -1) {
+ out.write(buffer, 0, n);
+ }
+
+ return out.toByteArray();
+ }
+ }
+
+ @SuppressLint("WrongConstant")
+ @Override
+ protected void onActivityResult(int requestCode, int resultCode, @Nullable Intent data) {
+ super.onActivityResult(requestCode, resultCode, data);
+
+ if (requestCode == REQ_OPEN_DOCUMENT && resultCode == Activity.RESULT_OK && data != null) {
+ Uri uri = data.getData();
+ if (uri == null) {
+ Log.w(TAG, "Picker returned null URI");
+ return;
+ }
+
+ // Keep only the permission bits the system granted in this result intent.
+ final int takeFlags = data.getFlags()
+ & (Intent.FLAG_GRANT_READ_URI_PERMISSION
+ | Intent.FLAG_GRANT_WRITE_URI_PERMISSION);
+
+ try {
+ // Persist the granted access so it survives app/device restarts.
+ getContentResolver().takePersistableUriPermission(uri, takeFlags);
+
+ // Persist the URI string somewhere durable.
+ // Example only; real persistence not implemented per your request.
+ String uriToPersist = uri.toString();
+ Log.d(TAG, "Persist this URI string: " + uriToPersist);
+
+ // Example immediate read:
+ byte[] bytes = readPersistedUriBytes(uriToPersist);
+ Log.d(TAG, "Read " + bytes.length + " bytes");
+
+ Intent ct = new Intent();
+ ct.putExtra("content_uri", uriToPersist);
+ setResult(Activity.RESULT_OK, ct);
+ finish();
+
+ } catch (SecurityException e) {
+ Log.e(TAG, "Failed to persist URI permission for " + uri, e);
+ } catch (IOException e) {
+ Log.e(TAG, "Failed to read URI " + uri, e);
+ }
+ }
+ }
+}