From 02ebb17dc656e231d76e41d3593b6a9950f2d931 Mon Sep 17 00:00:00 2001 From: David Madl Date: Thu, 19 Mar 2026 12:27:38 +0100 Subject: [PATCH] feat: SongPickerActivity --- app/build.gradle | 2 + app/src/main/AndroidManifest.xml | 3 + .../java/at/lockstep/app/MainActivity.java | 30 +++- .../main/java/at/lockstep/ui/SongItem.java | 40 +++++ .../at/lockstep/ui/SongPickerActivity.java | 170 ++++++++++++++++++ .../at/lockstep/ui/SongPickerAdapter.java | 66 +++++++ app/src/main/res/layout/activity_main.xml | 8 + .../main/res/layout/activity_song_picker.xml | 28 +++ app/src/main/res/layout/song_card.xml | 34 ++++ app/src/main/res/values/themes.xml | 3 +- gradle/libs.versions.toml | 4 + 11 files changed, 385 insertions(+), 3 deletions(-) create mode 100644 app/src/main/java/at/lockstep/ui/SongItem.java create mode 100644 app/src/main/java/at/lockstep/ui/SongPickerActivity.java create mode 100644 app/src/main/java/at/lockstep/ui/SongPickerAdapter.java create mode 100644 app/src/main/res/layout/activity_song_picker.xml create mode 100644 app/src/main/res/layout/song_card.xml 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" /> + +