feat: SAF file picker, fd to jni code

This commit is contained in:
2026-03-19 19:17:57 +01:00
parent 02ebb17dc6
commit 198dfc1630
10 changed files with 292 additions and 9 deletions

View File

@@ -39,6 +39,7 @@
</activity> </activity>
<activity android:name="at.lockstep.ui.SongPickerActivity" /> <activity android:name="at.lockstep.ui.SongPickerActivity" />
<activity android:name="at.lockstep.saf.SafPickerActivity" />
<service <service
android:name="at.lockstep.app.LstForegroundService" android:name="at.lockstep.app.LstForegroundService"

View File

@@ -74,3 +74,11 @@ PlaybackEngine::~PlaybackEngine() {
void PlaybackEngine::playBeat() { void PlaybackEngine::playBeat() {
if(mPlayer) mPlayer->setStartBeat(); if(mPlayer) mPlayer->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.
}

View File

@@ -15,6 +15,7 @@ public:
virtual ~PlaybackEngine(); virtual ~PlaybackEngine();
/** Play a beat sound. */ /** Play a beat sound. */
virtual void playBeat(); virtual void playBeat();
void playMusic(int fd);
private: private:
MixingPlayer *mPlayer; MixingPlayer *mPlayer;
std::string mFilesDir; std::string mFilesDir;

View File

@@ -40,4 +40,13 @@ Java_at_lockstep_pb_PlaybackEngine_native_1setDefaultStreamValues(JNIEnv *env,
oboe::DefaultStreamValues::FramesPerBurst = (int32_t) framesPerBurst; 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<PlaybackEngine *>(engineHandle);
engine->playMusic(fd);
}
} // extern "C" } // extern "C"

View File

@@ -5,6 +5,7 @@
#define LOG_TAG "mp3file" #define LOG_TAG "mp3file"
#include "mp3file.h" #include "mp3file.h"
#include <unistd.h>
#include <string.h> #include <string.h>
#include <cstdlib> #include <cstdlib>
#include "logging.h" #include "logging.h"
@@ -25,6 +26,7 @@ void mp3file_delete(MP3File *mp3file) {
free(mp3file->buffer); free(mp3file->buffer);
mp3file->buffer = 0; mp3file->buffer = 0;
} }
if(mp3file->android_fd) close(mp3file->android_fd);
free(mp3file); free(mp3file);
} }
@@ -93,3 +95,70 @@ on_error:
#undef handleError #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
}

View File

@@ -10,6 +10,7 @@
struct MP3File struct MP3File
{ {
mpg123_handle* handle; mpg123_handle* handle;
int android_fd;
int channels; int channels;
long rate; long rate;
long num_samples; long num_samples;
@@ -26,5 +27,6 @@ struct MP3File
MP3File* mp3file_init(mpg123_handle *handle); MP3File* mp3file_init(mpg123_handle *handle);
void mp3file_delete(MP3File *mp3file); void mp3file_delete(MP3File *mp3file);
MP3File* mp3file_open(const char *filename, int forceEncoding = 0); MP3File* mp3file_open(const char *filename, int forceEncoding = 0);
MP3File* mp3file_open_fd(int fd, int forceEncoding = 0);
#endif //SAMPLES_MP3FILE_H #endif //SAMPLES_MP3FILE_H

View File

@@ -10,13 +10,19 @@ import android.hardware.Sensor;
import android.hardware.SensorEvent; import android.hardware.SensorEvent;
import android.hardware.SensorEventListener; import android.hardware.SensorEventListener;
import android.hardware.SensorManager; import android.hardware.SensorManager;
import android.net.Uri;
import android.os.Build; import android.os.Build;
import android.os.IBinder; import android.os.IBinder;
import android.os.ParcelFileDescriptor;
import android.os.PowerManager; import android.os.PowerManager;
import android.widget.Toast;
import androidx.annotation.Nullable; import androidx.annotation.Nullable;
import androidx.core.app.NotificationCompat; import androidx.core.app.NotificationCompat;
import java.io.FileNotFoundException;
import java.io.IOException;
import at.lockstep.filter.StepDetector; import at.lockstep.filter.StepDetector;
import at.lockstep.pb.PlaybackEngine; import at.lockstep.pb.PlaybackEngine;
import at.lockstep.R; import at.lockstep.R;
@@ -36,9 +42,10 @@ public class LstForegroundService extends Service implements SensorEventListener
private StepDetector stepDetector; 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 intent = new Intent(context, LstForegroundService.class);
intent.setAction(ACTION_START); intent.setAction(ACTION_START);
intent.putExtra("content_uri", contentUri);
return intent; return intent;
} }
@@ -78,6 +85,14 @@ public class LstForegroundService extends Service implements SensorEventListener
if (intent != null) { if (intent != null) {
String action = intent.getAction(); String action = intent.getAction();
if (ACTION_START.equals(action)) { 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(); startCollection();
} else if (ACTION_STOP.equals(action)) { } else if (ACTION_STOP.equals(action)) {
stopCollectionAndSelf(); stopCollectionAndSelf();
@@ -86,6 +101,26 @@ public class LstForegroundService extends Service implements SensorEventListener
return START_STICKY; 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() { private void startCollection() {
if (isCollecting) { if (isCollecting) {
return; 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. // 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) // 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)
if(stepDetector != null) {
stepDetector.close(); stepDetector.close();
PlaybackEngine.delete(); PlaybackEngine.delete();
stepDetector = null;
}
super.onDestroy(); super.onDestroy();
} }

View File

@@ -12,6 +12,7 @@ import androidx.core.content.ContextCompat;
import at.lockstep.R; import at.lockstep.R;
import at.lockstep.pb.PlaybackEngine; import at.lockstep.pb.PlaybackEngine;
import at.lockstep.saf.SafPickerActivity;
import at.lockstep.ui.SongPickerActivity; import at.lockstep.ui.SongPickerActivity;
import android.widget.Toast; import android.widget.Toast;
@@ -22,6 +23,8 @@ public class MainActivity extends AppCompatActivity {
private Button btnPickSong; private Button btnPickSong;
private final ActivityResultLauncher<Intent> launcher; private final ActivityResultLauncher<Intent> launcher;
private String contentUri;
public MainActivity() { public MainActivity() {
launcher = registerForActivityResult( launcher = registerForActivityResult(
new ActivityResultContracts.StartActivityForResult(), new ActivityResultContracts.StartActivityForResult(),
@@ -31,6 +34,7 @@ public class MainActivity extends AppCompatActivity {
if (data != null) { if (data != null) {
String contentUri = data.getStringExtra("content_uri"); String contentUri = data.getStringExtra("content_uri");
Toast.makeText(this, "Item clicked: " + contentUri, Toast.LENGTH_LONG).show(); 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 -> btnStart.setOnClickListener(v ->
ContextCompat.startForegroundService( ContextCompat.startForegroundService(
MainActivity.this, MainActivity.this,
LstForegroundService.startIntent(MainActivity.this) LstForegroundService.startIntent(MainActivity.this, contentUri)
) )
); );
@@ -72,7 +76,7 @@ public class MainActivity extends AppCompatActivity {
}); });
btnPickSong.setOnClickListener(v -> { btnPickSong.setOnClickListener(v -> {
Intent intent = new Intent(this, SongPickerActivity.class); Intent intent = new Intent(this, SafPickerActivity.class);
launcher.launch(intent); launcher.launch(intent);
}); });
} }

View File

@@ -41,6 +41,11 @@ public class PlaybackEngine {
setDefaultStreamValues(context); setDefaultStreamValues(context);
Log.i("PlaybackEngine", "Hello PlaybackEngine"); Log.i("PlaybackEngine", "Hello PlaybackEngine");
mEngineHandle = native_createEngine(context.getFilesDir().toString(), resid); 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); return (mEngineHandle != 0);
} }
@@ -71,9 +76,16 @@ public class PlaybackEngine {
return mEngineHandle; 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 long native_createEngine(String filesDir, int resid);
private static native void native_deleteEngine(long engineHandle); private static native void native_deleteEngine(long engineHandle);
private static native void native_setDefaultStreamValues(int sampleRate, int framesPerBurst); private static native void native_setDefaultStreamValues(int sampleRate, int framesPerBurst);
private static native int native_mpg123_init(); private static native int native_mpg123_init();
private static native void native_playMusic(long engineHandle, int fd);
} }

View File

@@ -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<Intent> startActivityIntent = registerForActivityResult(
new ActivityResultContracts.StartActivityForResult(),
new ActivityResultCallback<ActivityResult>() {
@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);
}
}
}
}