diff --git a/app/build.gradle b/app/build.gradle
index 52e0514..7e1ab67 100644
--- a/app/build.gradle
+++ b/app/build.gradle
@@ -85,6 +85,8 @@ dependencies {
implementation libs.androidx.ui.graphics
implementation libs.androidx.ui.tooling.preview
implementation libs.androidx.material3
+ implementation libs.androidx.recyclerview
+ implementation libs.androidx.appcompat
testImplementation libs.junit
androidTestImplementation libs.androidx.junit
androidTestImplementation libs.androidx.espresso.core
diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index 7c35cd5..bcb941a 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -37,6 +37,9 @@
+
+
+
launcher;
+
+ public MainActivity() {
+ launcher = registerForActivityResult(
+ new ActivityResultContracts.StartActivityForResult(),
+ result -> {
+ if (result.getResultCode() == Activity.RESULT_OK) {
+ Intent data = result.getData();
+ if (data != null) {
+ String contentUri = data.getStringExtra("content_uri");
+ Toast.makeText(this, "Item clicked: " + contentUri, Toast.LENGTH_LONG).show();
+ }
+ }
+ }
+ );
+ }
@Override
protected void onCreate(Bundle savedInstanceState) {
@@ -23,6 +45,7 @@ public class MainActivity extends Activity {
btnStart = findViewById(R.id.btnStart);
btnStop = findViewById(R.id.btnStop);
btnMediaStoreBenchmark = findViewById(R.id.btnMediaStoreBenchmark);
+ btnPickSong = findViewById(R.id.btnPickSong);
// TODO: handle clicking START button twice
btnStart.setOnClickListener(v ->
@@ -47,5 +70,10 @@ public class MainActivity extends Activity {
Intent intent = new Intent(MainActivity.this, MediaStoreBenchmarkActivity.class);
startActivity(intent);
});
+
+ btnPickSong.setOnClickListener(v -> {
+ Intent intent = new Intent(this, SongPickerActivity.class);
+ launcher.launch(intent);
+ });
}
}
diff --git a/app/src/main/java/at/lockstep/ui/SongItem.java b/app/src/main/java/at/lockstep/ui/SongItem.java
new file mode 100644
index 0000000..18f9744
--- /dev/null
+++ b/app/src/main/java/at/lockstep/ui/SongItem.java
@@ -0,0 +1,40 @@
+package at.lockstep.ui;
+
+/**
+ * Item DTO for song picker.
+ */
+public class SongItem {
+ private String title;
+ private String artist;
+ private String contentUri;
+
+ public SongItem(String title, String artist, String contentUri) {
+ this.title = title;
+ this.artist = artist;
+ this.contentUri = contentUri;
+ }
+
+ public String getTitle() {
+ return title;
+ }
+
+ public void setTitle(String title) {
+ this.title = title;
+ }
+
+ public String getArtist() {
+ return artist;
+ }
+
+ public void setArtist(String artist) {
+ this.artist = artist;
+ }
+
+ public String getContentUri() {
+ return contentUri;
+ }
+
+ public void setContentUri(String contentUri) {
+ this.contentUri = contentUri;
+ }
+}
diff --git a/app/src/main/java/at/lockstep/ui/SongPickerActivity.java b/app/src/main/java/at/lockstep/ui/SongPickerActivity.java
new file mode 100644
index 0000000..54ce7c4
--- /dev/null
+++ b/app/src/main/java/at/lockstep/ui/SongPickerActivity.java
@@ -0,0 +1,170 @@
+package at.lockstep.ui;
+
+import android.Manifest;
+import android.app.Activity;
+import android.content.Intent;
+import android.content.pm.PackageManager;
+import android.os.Build;
+import android.os.Bundle;
+import android.os.SystemClock;
+import android.provider.MediaStore;
+
+import androidx.annotation.Nullable;
+import androidx.core.content.ContextCompat;
+import androidx.recyclerview.widget.LinearLayoutManager;
+import androidx.recyclerview.widget.RecyclerView;
+
+import java.util.ArrayList;
+import java.util.List;
+
+import at.lockstep.R;
+
+/**
+ * Choose a song from the device library.
+ */
+public class SongPickerActivity extends Activity implements SongPickerAdapter.OnItemClickListener {
+ private List songs = new ArrayList<>();
+
+ @Override
+ protected void onCreate(@Nullable Bundle savedInstanceState) {
+ super.onCreate(savedInstanceState);
+ setContentView(R.layout.activity_song_picker);
+
+ RecyclerView recyclerView = findViewById(R.id.recyclerView);
+
+ if (hasReadPermission()) {
+ loadSongList();
+ } else {
+ requestReadPermission();
+ }
+
+ recyclerView.setLayoutManager(new LinearLayoutManager(this));
+ recyclerView.setAdapter(new SongPickerAdapter(songs, this));
+ }
+
+ private static final int REQUEST_READ_PERMISSION = 1001;
+
+ @Override
+ public void onRequestPermissionsResult(
+ int requestCode,
+ String[] permissions,
+ int[] grantResults
+ ) {
+ super.onRequestPermissionsResult(requestCode, permissions, grantResults);
+
+ if (requestCode == REQUEST_READ_PERMISSION) {
+ boolean granted = grantResults.length > 0
+ && grantResults[0] == PackageManager.PERMISSION_GRANTED;
+
+ if (granted) {
+ loadSongList();
+ } else {
+ songs.add(new SongItem("Have No Songs - Permission denied", "Re-open app and try again.", null));
+ //resultTextView.setText("Permission denied.");
+ }
+ }
+ }
+
+ private boolean hasReadPermission() {
+ String permission;
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
+ permission = Manifest.permission.READ_MEDIA_AUDIO;
+ } else {
+ permission = Manifest.permission.READ_EXTERNAL_STORAGE;
+ }
+
+ return ContextCompat.checkSelfPermission(this, permission)
+ == PackageManager.PERMISSION_GRANTED;
+ }
+
+ private void requestReadPermission() {
+ String permission;
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
+ permission = Manifest.permission.READ_MEDIA_AUDIO;
+ } else {
+ permission = Manifest.permission.READ_EXTERNAL_STORAGE;
+ }
+
+ requestPermissions(
+ new String[]{ permission },
+ REQUEST_READ_PERMISSION
+ );
+ }
+
+ private void loadSongList() {
+ // TODO: cleanup, remove this list, etc
+ List musicList = new ArrayList<>();
+
+ android.net.Uri collection = MediaStore.Audio.Media.EXTERNAL_CONTENT_URI;
+
+ String[] projection = new String[] {
+ MediaStore.Audio.Media._ID,
+ MediaStore.Audio.Media.TITLE,
+ MediaStore.Audio.Media.ARTIST,
+ MediaStore.Audio.Media.DATA
+ };
+
+ String selection = MediaStore.Audio.Media.IS_MUSIC + " != 0";
+
+ long start = SystemClock.elapsedRealtime();
+
+ //
+ // perform MediaStore query
+ //
+ try (android.database.Cursor cursor = getContentResolver().query(
+ collection,
+ projection,
+ selection,
+ null,
+ MediaStore.Audio.Media.TITLE + " ASC"
+ )) {
+ if (cursor != null) {
+ int idColumn = cursor.getColumnIndexOrThrow(MediaStore.Audio.Media._ID);
+ int titleColumn = cursor.getColumnIndexOrThrow(MediaStore.Audio.Media.TITLE);
+ int artistColumn = cursor.getColumnIndexOrThrow(MediaStore.Audio.Media.ARTIST);
+ int dataColumn = cursor.getColumnIndex(MediaStore.Audio.Media.DATA);
+
+ while (cursor.moveToNext()) {
+ String contentUri = cursor.getString(idColumn); // the content:// Uri for the MediaStore item
+ String title = cursor.getString(titleColumn);
+ String artist = cursor.getString(artistColumn);
+ String path = dataColumn != -1 ? cursor.getString(dataColumn) : null;
+
+ if (path != null) {
+ musicList.add(title + "\n" + path);
+ songs.add(new SongItem(title, artist, contentUri));
+ } else {
+ musicList.add(title + "\n[path unavailable]");
+ }
+ }
+ }
+ }
+
+ long elapsedMs = SystemClock.elapsedRealtime() - start;
+
+ StringBuilder header = new StringBuilder();
+ header.append("Found ").append(musicList.size()).append(" music files\n");
+ header.append("Query time: ").append(elapsedMs).append(" ms\n\n");
+
+ //resultTextView.setText(header.toString() + String.join("\n\n", musicList));
+
+ // 200 music files in 32 ms
+ // paths like "/storage/emulated/0/Download/...mp3"
+ }
+
+ @Override
+ public void onItemClick(SongItem item) {
+ if(item.getContentUri() == null) {
+ // clicked the prompt for missing permissions?
+ // try acquiring them again.
+ // nice-to: test this.
+ requestReadPermission();
+ return;
+ }
+
+ Intent data = new Intent();
+ data.putExtra("content_uri", item.getContentUri());
+ setResult(Activity.RESULT_OK, data);
+ finish();
+ }
+}
diff --git a/app/src/main/java/at/lockstep/ui/SongPickerAdapter.java b/app/src/main/java/at/lockstep/ui/SongPickerAdapter.java
new file mode 100644
index 0000000..17fd35e
--- /dev/null
+++ b/app/src/main/java/at/lockstep/ui/SongPickerAdapter.java
@@ -0,0 +1,66 @@
+package at.lockstep.ui;
+
+import android.view.LayoutInflater;
+import android.view.View;
+import android.view.ViewGroup;
+import android.widget.TextView;
+
+import androidx.annotation.NonNull;
+import androidx.recyclerview.widget.RecyclerView;
+
+import java.util.List;
+
+import at.lockstep.R;
+
+/**
+ * RecyclerView Adapter for song picker.
+ */
+public class SongPickerAdapter extends RecyclerView.Adapter {
+ private List songList;
+ private final OnItemClickListener listener;
+
+ public interface OnItemClickListener {
+ void onItemClick(SongItem item);
+ }
+ public SongPickerAdapter(List songList, OnItemClickListener listener) {
+ this.songList = songList;
+ this.listener = listener;
+ }
+
+ @NonNull
+ @Override
+ public SongPickerViewHolder onCreateViewHolder(@NonNull ViewGroup parent, int viewType) {
+ View view = LayoutInflater.from(parent.getContext())
+ .inflate(R.layout.song_card, parent, false);
+ return new SongPickerViewHolder(view);
+ }
+
+ @Override
+ public void onBindViewHolder(@NonNull SongPickerViewHolder holder, int position) {
+ SongItem songItem = songList.get(position);
+ holder.songTitle.setText(songItem.getTitle());
+ holder.songArtist.setText(songItem.getArtist());
+ holder.itemView.setOnClickListener(new View.OnClickListener() {
+ @Override
+ public void onClick(View view) {
+ listener.onItemClick(songItem);
+ }
+ });
+ }
+
+ @Override
+ public int getItemCount() {
+ return songList.size();
+ }
+
+ static class SongPickerViewHolder extends RecyclerView.ViewHolder {
+ TextView songTitle;
+ TextView songArtist;
+ public SongPickerViewHolder(@NonNull View itemView) {
+ super(itemView);
+ songTitle = itemView.findViewById(R.id.songTitle);
+ songArtist = itemView.findViewById(R.id.songArtist);
+ }
+
+ }
+}
diff --git a/app/src/main/res/layout/activity_main.xml b/app/src/main/res/layout/activity_main.xml
index f8cc49a..2690754 100644
--- a/app/src/main/res/layout/activity_main.xml
+++ b/app/src/main/res/layout/activity_main.xml
@@ -24,4 +24,12 @@
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="Benchmark media store" />
+
+
+
\ No newline at end of file
diff --git a/app/src/main/res/layout/activity_song_picker.xml b/app/src/main/res/layout/activity_song_picker.xml
new file mode 100644
index 0000000..395ff83
--- /dev/null
+++ b/app/src/main/res/layout/activity_song_picker.xml
@@ -0,0 +1,28 @@
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/app/src/main/res/layout/song_card.xml b/app/src/main/res/layout/song_card.xml
new file mode 100644
index 0000000..857fbbf
--- /dev/null
+++ b/app/src/main/res/layout/song_card.xml
@@ -0,0 +1,34 @@
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/app/src/main/res/values/themes.xml b/app/src/main/res/values/themes.xml
index 5f919fd..d08d50a 100644
--- a/app/src/main/res/values/themes.xml
+++ b/app/src/main/res/values/themes.xml
@@ -1,5 +1,4 @@
-
-
+
\ No newline at end of file
diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml
index afd5358..bb63fed 100644
--- a/gradle/libs.versions.toml
+++ b/gradle/libs.versions.toml
@@ -11,6 +11,8 @@ composeBom = "2024.04.01"
logbackAndroid = "2.0.0"
oboe = "1.10.0"
slf4jApi = "1.7.30"
+recyclerview = "1.3.1"
+appcompat = "1.7.1"
[libraries]
androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" }
@@ -30,6 +32,8 @@ androidx-material3 = { group = "androidx.compose.material3", name = "material3"
logback-android = { module = "com.github.tony19:logback-android", version.ref = "logbackAndroid" }
oboe = { module = "com.google.oboe:oboe", version.ref = "oboe" }
slf4j-api = { module = "org.slf4j:slf4j-api", version.ref = "slf4jApi" }
+androidx-recyclerview = { group = "androidx.recyclerview", name = "recyclerview", version.ref = "recyclerview" }
+androidx-appcompat = { group = "androidx.appcompat", name = "appcompat", version.ref = "appcompat" }
[plugins]
android-application = { id = "com.android.application", version.ref = "agp" }