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