diff --git a/README.md b/README.md index a2ef922..c84d6ed 100644 --- a/README.md +++ b/README.md @@ -1,74 +1,104 @@ # RubyFPV Android Viewer -Android app that displays the live video feed from a [RubyFPV](https://rubyfpv.com) ground station over USB-C tethering. - -Plug your phone into the Ruby ground station, enable USB tethering, and see exactly what the pilot sees. - -## How it works - -1. Phone connects to Ruby ground station via USB cable -2. USB tethering is enabled on the phone (creates a network link) -3. Ruby detects the phone and sends raw H.264 video over UDP port 5001 -4. The app decodes and displays the video in fullscreen with minimal latency +Android companion app for [RubyFPV](https://rubyfpv.com). Browse, download, play and +manage the onboard `.ts` recordings off your drone's SD card over Wi-Fi — DJI-Fly style — +then tap **Return to FPV mode** to reboot the drone back to flight. A live USB viewer +(the original `MainActivity`) is still available behind the ⋮ menu. + +## Phone-transfer workflow + +1. **On the Ruby Ground Station**, press **Enter phone-transfer mode**. The drone reboots + its radio into an open Wi-Fi access point named `RubyFPV-` at `192.168.4.1`. +2. **On your phone**, join that Wi-Fi network. +3. Open the app and tap **Connect**. It SSHes the drone and lists the onboard recordings. +4. Browse clips with thumbnails, **download**, **play**, and **delete** as you like. +5. When you're done, tap **⋮ → Return to FPV mode**. The drone reboots out of + phone-transfer AP mode back into normal flight/FPV (ready to fly again in ~45 s). + +The drone runs **dropbear**, whose build ships **no SFTP / SCP subsystem**, so the app +never opens an SFTP channel. It drives plain `exec` channels instead: listing with +`stat`, transferring by streaming `cat`. Recordings live in `/mnt/mmcblk0p1/ruby` and the +default login is `root@192.168.4.1:22` (editable in **⋮ → Connection settings**). + +## Recordings — download & playback + +- **Thumbnails** are decoded on-device from the first few MB of each clip (it starts on a + keyframe). The onboard recordings are **HEVC in MPEG-TS**, which the Android framework + demuxer reports as 0 tracks; the app uses media3 1.6.0's + `ExperimentalFrameExtractor` — the same ExoPlayer pipeline used for playback — to pull + the first frame. +- **Playback** plays the downloaded `.ts` directly in ExoPlayer (no remux / re-encode). +- **Multi-select**: long-press a clip to enter selection mode, then bulk-delete. +- **Per-item delete** lets you choose where to remove a clip from: **phone**, **drone SD + card**, or **both**. +- **Share** a downloaded clip to any app via the system share sheet. + +## Live USB viewer + +The original USB viewer is still present, reachable from **⋮ → Live view (USB)**: + +1. Connect the phone to the Ruby ground station via USB cable and enable USB tethering. +2. Ruby forwards raw H.264 video over UDP port 5001. +3. The app decodes and displays it fullscreen with minimal latency. + +To enable forwarding on the ground station: **Controller > Video Forward**, turn on +**Video Forward To USB Device**, type **Raw (H264)**, port **5001**. ## Features -- Hardware H.264 decoding via Android MediaCodec -- Fullscreen landscape display -- Auto-recovery on stream loss (3-second watchdog) -- Stream stats overlay (tap screen to toggle): bitrate, packet rate, NAL rate -- Minimal latency — designed for FPV use +- Recordings browser with thumbnails, file size, date and on-drone / on-device state +- ExoPlayer playback of `.ts` HEVC clips (hardware decode, no remux) +- Multi-select (long-press) with bulk delete +- Per-item delete: phone / drone SD card / both +- Share downloaded clips via the system share sheet +- **Return to FPV mode** — reboots the drone out of phone-transfer AP back into flight +- Live USB viewer (raw H.264 over UDP 5001) behind the ⋮ menu +- In-app diagnostics / debug log viewer ## Requirements -- Android 7.0+ (API 24) -- USB data cable (not charge-only) -- RubyFPV ground station with USB video forwarding enabled - -## Ruby ground station setup - -1. Go to **Controller > Video Forward** in the Ruby menu -2. Enable **Video Forward To USB Device** -3. Set type to **Raw (H264)** -4. Port: **5001** (default) - -## Install +- Android 7.0+ (min SDK 24, target SDK 35) +- A RubyFPV drone that supports phone-transfer mode (open AP `RubyFPV-` @ 192.168.4.1) +- For the live USB viewer: a USB data cable and a Ruby ground station with USB video + forwarding enabled -Download the latest APK from [GitHub Actions](https://github.com/wkumik/RubyFPV-Android-Viewer/actions) (build artifacts) or build from source. +## Build from source -### Build from source +Requires **Java 21** and the Android SDK (`ANDROID_HOME` set, or a `local.properties` +with `sdk.dir`). -``` +```sh git clone https://github.com/wkumik/RubyFPV-Android-Viewer.git cd RubyFPV-Android-Viewer -./gradlew assembleDebug +./gradlew :app:assembleDebug ``` -APK will be at `app/build/outputs/apk/debug/app-debug.apk` +The APK is stamped with its version and lands at: -## Testing without a ground station +``` +app/build/outputs/apk/debug/RubyFPV-v-b-debug.apk +``` -You can test the app using ffmpeg from a PC while the phone is USB-tethered to the PC: +(e.g. `RubyFPV-v1.6-b8-debug.apk`). -``` -ffmpeg -f lavfi -i testsrc=size=1280x720:rate=30 \ - -pix_fmt yuv420p -c:v libx264 -profile:v baseline \ - -tune zerolatency -g 30 -bsf:v dump_extra \ - -f h264 udp://:5001?pkt_size=1024 +Install it with adb: + +```sh +adb install -r app/build/outputs/apk/debug/RubyFPV-v1.6-b8-debug.apk ``` -## Roadmap +## Tech notes -See [ROADMAP.md](ROADMAP.md) for planned features including: -- Ruby OSD overlay (V2) -- H.265 support (V3) -- Screen recording / DVR (V4) +- media3 1.6.0 (ExoPlayer + UI + Transformer's `ExperimentalFrameExtractor`) +- jsch (`com.github.mwiede:jsch`) for SSH over the drone's dropbear (exec channels only — + no SFTP) +- Min SDK 24, target / compile SDK 35, Java 17 source/target ## Credits - [RubyFPV](https://rubyfpv.com) by Petru Soroaga -- Video decoding architecture inspired by [OpenIPC Decoder](https://github.com/OpenIPC/decoder) (MIT License) -- NAL parsing based on [Consti10/myMediaCodecPlayer-for-FPV](https://github.com/Consti10/myMediaCodecPlayer-for-FPV) +- Live USB viewer NAL parsing based on + [Consti10/myMediaCodecPlayer-for-FPV](https://github.com/Consti10/myMediaCodecPlayer-for-FPV) ## License diff --git a/ROADMAP.md b/ROADMAP.md index ce8359d..794045c 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -1,5 +1,21 @@ # RubyFPV Android Viewer — Roadmap +## Recordings — DJI-style transfer & preview (primary) + +Get onboard HEVC recordings off the drone over its Wi-Fi, review and share them. + +- [x] Material 3 dark "album" UI — thumbnails, duration, size, date +- [x] SSH connection to the drone over dropbear (exec + `cat`, no SFTP subsystem) +- [x] List `/mnt/mmcblk0p1/ruby/*.ts` (+ paired `.osd`) +- [x] Download with progress +- [x] Lossless `.ts` → `.mp4` remux on-device (MediaExtractor + MediaMuxer) +- [x] Native HEVC playback (VideoView) with seeking +- [x] First-frame thumbnails (MediaMetadataRetriever) +- [x] Share sheet (`video/mp4` via FileProvider) +- [x] Delete from drone / phone +- [ ] In-app Wi-Fi join wired to drone AP (pending drone "phone-transfer mode") +- [ ] End-to-end test against the AP, then package as a Ruby-update zip + ## V1 — Basic Video Viewer (current) Core functionality: plug in phone via USB-C, see what the pilot sees. diff --git a/app/build.gradle b/app/build.gradle index 355a599..630819f 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -10,15 +10,19 @@ android { applicationId "com.rubyfpv.viewer" minSdk 24 targetSdk 35 - versionCode 1 - versionName "1.0" + versionCode 10 + versionName "2.0" + } + + buildFeatures { + buildConfig = true } buildTypes { release { minifyEnabled = true shrinkResources = true - proguardFiles getDefaultProguardFile('proguard-android-optimize.txt') + proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro' } } @@ -26,4 +30,21 @@ android { sourceCompatibility = JavaVersion.VERSION_17 targetCompatibility = JavaVersion.VERSION_17 } + + // Stamp the version into the APK filename, e.g. RubyFPV-v1.2-b3-debug.apk + applicationVariants.all { variant -> + variant.outputs.all { output -> + outputFileName = "RubyFPV-v${variant.versionName}-b${variant.versionCode}-${variant.buildType.name}.apk" + } + } +} + +dependencies { + implementation libs.material + implementation libs.constraintlayout + implementation libs.swiperefreshlayout + implementation libs.jsch + implementation libs.media3.exoplayer + implementation libs.media3.ui + implementation libs.media3.transformer } diff --git a/app/proguard-rules.pro b/app/proguard-rules.pro new file mode 100644 index 0000000..f7ffdb5 --- /dev/null +++ b/app/proguard-rules.pro @@ -0,0 +1,5 @@ +# JSch (mwiede fork) loads ciphers/kex/mac/hostkey classes reflectively by name. +-keep class com.jcraft.jsch.** { *; } +-keep class com.jcraft.jzlib.** { *; } +-dontwarn com.jcraft.jsch.** +-dontwarn org.ietf.jgss.** diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 785a2eb..eacfc1c 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -1,21 +1,61 @@ + + + + + + + + + + + + + + android:label="@string/app_name"> + + + + + + + + + + diff --git a/app/src/main/java/com/rubyfpv/viewer/player/PlaybackActivity.java b/app/src/main/java/com/rubyfpv/viewer/player/PlaybackActivity.java new file mode 100644 index 0000000..6a7c019 --- /dev/null +++ b/app/src/main/java/com/rubyfpv/viewer/player/PlaybackActivity.java @@ -0,0 +1,127 @@ +package com.rubyfpv.viewer.player; + +import android.content.Intent; +import android.net.Uri; +import android.os.Bundle; +import android.view.View; +import android.widget.Toast; + +import androidx.annotation.Nullable; +import androidx.appcompat.app.AppCompatActivity; +import androidx.core.content.FileProvider; +import androidx.media3.common.MediaItem; +import androidx.media3.common.PlaybackException; +import androidx.media3.common.Player; +import androidx.media3.exoplayer.ExoPlayer; +import androidx.media3.ui.PlayerView; + +import com.google.android.material.appbar.MaterialToolbar; +import com.rubyfpv.viewer.R; + +import java.io.File; + +/** + * Plays a downloaded/remuxed clip with ExoPlayer (media3). ExoPlayer reads the + * file in-process, so app-private files in {@code Android/data//} play fine — + * unlike {@code MediaPlayer}/{@code VideoView}, whose {@code mediaserver} process + * can't open them ("no video player available"). It also demuxes raw {@code .ts} + * if the remux to MP4 failed. + */ +public class PlaybackActivity extends AppCompatActivity { + + public static final String EXTRA_PATH = "path"; + public static final String EXTRA_URI = "uri"; + public static final String EXTRA_TITLE = "title"; + + private ExoPlayer player; + private PlayerView playerView; + private File file; // set when playing an app-local file (fallback) + private Uri mediaUri; // set when playing a content:// gallery item + + @Override + protected void onCreate(@Nullable Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + setContentView(R.layout.activity_playback); + + String uriStr = getIntent().getStringExtra(EXTRA_URI); + String path = getIntent().getStringExtra(EXTRA_PATH); + String title = getIntent().getStringExtra(EXTRA_TITLE); + if (uriStr != null) { + mediaUri = Uri.parse(uriStr); + } else if (path != null) { + file = new File(path); + mediaUri = Uri.fromFile(file); + } else { + finish(); + return; + } + + MaterialToolbar toolbar = findViewById(R.id.player_toolbar); + toolbar.setTitle(title != null ? title : (file != null ? file.getName() : "Recording")); + toolbar.setNavigationOnClickListener(v -> finish()); + toolbar.setOnMenuItemClickListener(item -> { + if (item.getItemId() == R.id.menu_share) { share(); return true; } + return false; + }); + + playerView = findViewById(R.id.player_view); + // Mirror the controller visibility onto the toolbar for a clean look. + playerView.setControllerVisibilityListener( + (PlayerView.ControllerVisibilityListener) visibility -> + toolbar.setVisibility(visibility)); + } + + private void initPlayer() { + if (player != null) return; + player = new ExoPlayer.Builder(this).build(); + playerView.setPlayer(player); + player.addListener(new Player.Listener() { + @Override + public void onPlayerError(PlaybackException error) { + Toast.makeText(PlaybackActivity.this, + "Playback error: " + error.getErrorCodeName(), Toast.LENGTH_LONG).show(); + } + }); + player.setMediaItem(MediaItem.fromUri(mediaUri)); + player.prepare(); + player.setPlayWhenReady(true); + } + + private void releasePlayer() { + if (player != null) { + player.release(); + player = null; + } + } + + @Override + protected void onStart() { + super.onStart(); + initPlayer(); + } + + @Override + protected void onStop() { + super.onStop(); + releasePlayer(); + } + + private void share() { + try { + Uri uri; + if (mediaUri != null && "content".equals(mediaUri.getScheme())) { + uri = mediaUri; // gallery item — share directly with a read grant + } else { + uri = FileProvider.getUriForFile( + this, getPackageName() + ".fileprovider", file); + } + Intent send = new Intent(Intent.ACTION_SEND); + send.setType("video/mp4"); + send.putExtra(Intent.EXTRA_STREAM, uri); + send.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION); + startActivity(Intent.createChooser(send, getString(R.string.share_title))); + } catch (Exception e) { + Toast.makeText(this, R.string.toast_share_failed, Toast.LENGTH_SHORT).show(); + } + } +} diff --git a/app/src/main/java/com/rubyfpv/viewer/recordings/DebugLog.java b/app/src/main/java/com/rubyfpv/viewer/recordings/DebugLog.java new file mode 100644 index 0000000..27c1561 --- /dev/null +++ b/app/src/main/java/com/rubyfpv/viewer/recordings/DebugLog.java @@ -0,0 +1,40 @@ +package com.rubyfpv.viewer.recordings; + +import android.os.SystemClock; +import android.util.Log; + +import java.util.ArrayDeque; +import java.util.Locale; + +/** + * Tiny in-app ring-buffer log so the thumbnail/transfer pipeline can be inspected + * on-device (⋮ → Diagnostics) without needing adb/logcat. + */ +public final class DebugLog { + + private static final String TAG = "RubyDiag"; + private static final int MAX = 500; + private static final ArrayDeque LINES = new ArrayDeque<>(); + private static final long T0 = SystemClock.uptimeMillis(); + + private DebugLog() {} + + public static synchronized void add(String msg) { + String line = String.format(Locale.US, "%6.1fs %s", + (SystemClock.uptimeMillis() - T0) / 1000.0, msg); + LINES.addLast(line); + while (LINES.size() > MAX) LINES.removeFirst(); + Log.i(TAG, msg); + } + + public static synchronized String dump() { + if (LINES.isEmpty()) return "(no diagnostics yet — connect and let thumbnails load)"; + StringBuilder sb = new StringBuilder(); + for (String s : LINES) sb.append(s).append('\n'); + return sb.toString(); + } + + public static synchronized void clear() { + LINES.clear(); + } +} diff --git a/app/src/main/java/com/rubyfpv/viewer/recordings/Format.java b/app/src/main/java/com/rubyfpv/viewer/recordings/Format.java new file mode 100644 index 0000000..318cb7d --- /dev/null +++ b/app/src/main/java/com/rubyfpv/viewer/recordings/Format.java @@ -0,0 +1,38 @@ +package com.rubyfpv.viewer.recordings; + +import android.text.format.DateUtils; + +import java.util.Locale; + +/** Small formatting helpers for sizes, durations and dates. */ +public final class Format { + + private Format() {} + + public static String size(long bytes) { + if (bytes <= 0) return "—"; + if (bytes < 1024) return bytes + " B"; + double kb = bytes / 1024.0; + if (kb < 1024) return String.format(Locale.US, "%.0f KB", kb); + double mb = kb / 1024.0; + if (mb < 1024) return String.format(Locale.US, "%.1f MB", mb); + return String.format(Locale.US, "%.2f GB", mb / 1024.0); + } + + public static String clock(long ms) { + if (ms <= 0) return ""; + long totalSec = ms / 1000; + long h = totalSec / 3600; + long m = (totalSec % 3600) / 60; + long s = totalSec % 60; + if (h > 0) return String.format(Locale.US, "%d:%02d:%02d", h, m, s); + return String.format(Locale.US, "%d:%02d", m, s); + } + + public static String date(long epochSec) { + if (epochSec <= 0) return ""; + long now = System.currentTimeMillis(); + return DateUtils.getRelativeTimeSpanString( + epochSec * 1000L, now, DateUtils.MINUTE_IN_MILLIS).toString(); + } +} diff --git a/app/src/main/java/com/rubyfpv/viewer/recordings/GalleryStore.java b/app/src/main/java/com/rubyfpv/viewer/recordings/GalleryStore.java new file mode 100644 index 0000000..46448df --- /dev/null +++ b/app/src/main/java/com/rubyfpv/viewer/recordings/GalleryStore.java @@ -0,0 +1,227 @@ +package com.rubyfpv.viewer.recordings; + +import android.content.ContentResolver; +import android.content.ContentUris; +import android.content.ContentValues; +import android.content.Context; +import android.database.Cursor; +import android.net.Uri; +import android.os.Build; +import android.os.Environment; +import android.provider.MediaStore; + +import java.io.File; +import java.io.FileInputStream; +import java.io.InputStream; +import java.io.OutputStream; +import java.util.ArrayList; +import java.util.List; + +/** + * Wraps {@link MediaStore} for the {@code Movies/RubyFPV} video collection — the + * single store of record for downloaded+converted clips. + * + *

On API 29+ files live in the public {@code Movies/RubyFPV} relative path, + * owned by this app (so we may delete them without a {@code RecoverableSecurity- + * Exception}). They appear in Gallery/Photos and survive an app uninstall. On + * pre-Q we write into the public {@code Movies/RubyFPV} dir and register a + * MediaStore row.

+ */ +public final class GalleryStore { + + /** Public sub-album under Movies. */ + private static final String SUBDIR = "RubyFPV"; + /** Q+ RELATIVE_PATH value, e.g. "Movies/RubyFPV". */ + private static final String REL_PATH = Environment.DIRECTORY_MOVIES + "/" + SUBDIR; + + private GalleryStore() {} + + /** A clip discovered in the gallery collection. */ + public static final class GalleryItem { + public Uri uri; + public String displayName; + public String stem; + public long sizeBytes; + public long mtimeEpochSec; + public long durationMs = -1; + } + + private static Uri videoCollection() { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { + return MediaStore.Video.Media.getContentUri(MediaStore.VOLUME_EXTERNAL_PRIMARY); + } + return MediaStore.Video.Media.EXTERNAL_CONTENT_URI; + } + + private static String ensureMp4(String displayName) { + if (displayName == null || displayName.isEmpty()) return "clip.mp4"; + return displayName.endsWith(".mp4") ? displayName : displayName + ".mp4"; + } + + /** + * Publish {@code mp4}'s bytes into {@code Movies/RubyFPV} as {@code displayName}. + * + * @return the published content {@link Uri}, or {@code null} on failure. + */ + public static Uri publish(Context ctx, File mp4, String displayName) { + if (mp4 == null || !mp4.exists() || mp4.length() == 0) { + DebugLog.add("gallery publish: source missing/empty " + mp4); + return null; + } + final String name = ensureMp4(displayName); + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { + return publishQ(ctx, mp4, name); + } + return publishLegacy(ctx, mp4, name); + } + + private static Uri publishQ(Context ctx, File mp4, String name) { + final ContentResolver cr = ctx.getContentResolver(); + final Uri collection = videoCollection(); + + ContentValues cv = new ContentValues(); + cv.put(MediaStore.Video.Media.DISPLAY_NAME, name); + cv.put(MediaStore.Video.Media.MIME_TYPE, "video/mp4"); + cv.put(MediaStore.Video.Media.RELATIVE_PATH, REL_PATH); + cv.put(MediaStore.Video.Media.IS_PENDING, 1); + + Uri uri = null; + try { + uri = cr.insert(collection, cv); + if (uri == null) { + DebugLog.add("gallery publish: insert returned null for " + name); + return null; + } + try (OutputStream out = cr.openOutputStream(uri); + InputStream in = new FileInputStream(mp4)) { + if (out == null) throw new java.io.IOException("openOutputStream null"); + byte[] buf = new byte[64 * 1024]; + int n; + while ((n = in.read(buf)) > 0) out.write(buf, 0, n); + out.flush(); + } + ContentValues clear = new ContentValues(); + clear.put(MediaStore.Video.Media.IS_PENDING, 0); + cr.update(uri, clear, null, null); + DebugLog.add("gallery publish OK: " + name + " -> " + uri); + return uri; + } catch (Exception e) { + DebugLog.add("gallery publish FAILED (" + name + "): " + e.getMessage()); + if (uri != null) { + try { cr.delete(uri, null, null); } catch (Exception ignored) {} + } + return null; + } + } + + private static Uri publishLegacy(Context ctx, File mp4, String name) { + try { + File baseDir = new File( + Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_MOVIES), + SUBDIR); + //noinspection ResultOfMethodCallIgnored + baseDir.mkdirs(); + File out = new File(baseDir, name); + try (InputStream in = new FileInputStream(mp4); + OutputStream os = new java.io.FileOutputStream(out)) { + byte[] buf = new byte[64 * 1024]; + int n; + while ((n = in.read(buf)) > 0) os.write(buf, 0, n); + os.flush(); + } + ContentValues cv = new ContentValues(); + cv.put(MediaStore.Video.Media.DISPLAY_NAME, name); + cv.put(MediaStore.Video.Media.MIME_TYPE, "video/mp4"); + cv.put(MediaStore.Video.Media.DATA, out.getAbsolutePath()); + Uri uri = ctx.getContentResolver() + .insert(MediaStore.Video.Media.EXTERNAL_CONTENT_URI, cv); + DebugLog.add("gallery publish (legacy) " + + (uri != null ? "OK" : "row null") + ": " + out.getAbsolutePath()); + return uri; + } catch (Exception e) { + DebugLog.add("gallery publish (legacy) FAILED (" + name + "): " + e.getMessage()); + return null; + } + } + + /** List clips under {@code Movies/RubyFPV}. Newest items are not guaranteed first. */ + public static List query(Context ctx) { + List out = new ArrayList<>(); + final Uri collection = videoCollection(); + final boolean q = Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q; + + String[] projection = q + ? new String[]{ + MediaStore.Video.Media._ID, + MediaStore.Video.Media.DISPLAY_NAME, + MediaStore.Video.Media.SIZE, + MediaStore.Video.Media.DATE_MODIFIED, + MediaStore.Video.Media.DURATION, + } + : new String[]{ + MediaStore.Video.Media._ID, + MediaStore.Video.Media.DISPLAY_NAME, + MediaStore.Video.Media.SIZE, + MediaStore.Video.Media.DATE_MODIFIED, + MediaStore.Video.Media.DURATION, + MediaStore.Video.Media.DATA, + }; + + String selection; + String[] args; + if (q) { + selection = MediaStore.Video.Media.RELATIVE_PATH + " LIKE ?"; + args = new String[]{ REL_PATH + "%" }; + } else { + selection = MediaStore.Video.Media.DATA + " LIKE ?"; + args = new String[]{ "%/" + REL_PATH + "/%" }; + } + + try (Cursor c = ctx.getContentResolver().query( + collection, projection, selection, args, + MediaStore.Video.Media.DATE_MODIFIED + " DESC")) { + if (c == null) return out; + int idCol = c.getColumnIndexOrThrow(MediaStore.Video.Media._ID); + int nameCol = c.getColumnIndexOrThrow(MediaStore.Video.Media.DISPLAY_NAME); + int sizeCol = c.getColumnIndexOrThrow(MediaStore.Video.Media.SIZE); + int dateCol = c.getColumnIndexOrThrow(MediaStore.Video.Media.DATE_MODIFIED); + int durCol = c.getColumnIndexOrThrow(MediaStore.Video.Media.DURATION); + while (c.moveToNext()) { + GalleryItem g = new GalleryItem(); + long id = c.getLong(idCol); + g.uri = ContentUris.withAppendedId(collection, id); + g.displayName = c.getString(nameCol); + g.stem = stemOf(g.displayName); + g.sizeBytes = c.getLong(sizeCol); + g.mtimeEpochSec = c.getLong(dateCol); + long dur = c.isNull(durCol) ? -1 : c.getLong(durCol); + g.durationMs = dur > 0 ? dur : -1; + out.add(g); + } + DebugLog.add("gallery query: " + out.size() + " item(s)"); + } catch (Exception e) { + DebugLog.add("gallery query FAILED: " + e.getMessage()); + } + return out; + } + + /** Delete a clip we created. App owns it on Q+, so no recoverable-security prompt. */ + public static boolean delete(Context ctx, Uri uri) { + if (uri == null) return false; + try { + int n = ctx.getContentResolver().delete(uri, null, null); + DebugLog.add("gallery delete " + (n > 0 ? "OK" : "no-op") + ": " + uri); + return n > 0; + } catch (Exception e) { + DebugLog.add("gallery delete FAILED (" + uri + "): " + e.getMessage()); + return false; + } + } + + private static String stemOf(String displayName) { + if (displayName == null) return ""; + return displayName.endsWith(".mp4") + ? displayName.substring(0, displayName.length() - 4) + : displayName; + } +} diff --git a/app/src/main/java/com/rubyfpv/viewer/recordings/Mp4Export.java b/app/src/main/java/com/rubyfpv/viewer/recordings/Mp4Export.java new file mode 100644 index 0000000..4531e04 --- /dev/null +++ b/app/src/main/java/com/rubyfpv/viewer/recordings/Mp4Export.java @@ -0,0 +1,114 @@ +package com.rubyfpv.viewer.recordings; + +import android.content.Context; +import android.net.Uri; +import android.os.Handler; +import android.os.HandlerThread; + +import androidx.media3.common.MediaItem; +import androidx.media3.transformer.Composition; +import androidx.media3.transformer.ExportException; +import androidx.media3.transformer.ExportResult; +import androidx.media3.transformer.InAppMp4Muxer; +import androidx.media3.transformer.Transformer; + +import java.io.File; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicBoolean; + +/** + * Remux an onboard HEVC clip from its MPEG-TS container into MP4 so it can be + * shared to apps that reject {@code .ts} (Instagram, WhatsApp, TikTok, …). + * + *

The Android framework muxer ({@link android.media.MediaMuxer}) can't demux + * HEVC-in-TS — it reports zero tracks (see {@link Thumbs}) — so we use media3's + * {@link Transformer}, which drives the same ExoPlayer {@code TsExtractor} that + * plays and thumbnails the clip. With no effects applied and a codec the MP4 + * container supports (HEVC), Transformer transmuxes (stream-copy): no + * decode/encode, so it's fast and lossless — only the container changes.

+ * + *

{@link #remux} blocks the calling (background) thread until the export + * finishes or times out. Transformer must be created and driven from a single + * {@link android.os.Looper}, so we host it on a private {@link HandlerThread} + * and bridge completion back with a {@link CountDownLatch}. The MP4 is written + * to a {@code .part} sibling and renamed into place only on success, so a + * crash/kill mid-convert never leaves a half-written {@code .mp4} that later + * looks playable.

+ */ +public final class Mp4Export { + + /** Generous ceiling; a transmux is I/O-bound, not CPU, so this is rarely hit. */ + private static final long TIMEOUT_MIN = 10; + + private Mp4Export() {} + + /** + * Remux {@code src} (.ts) into {@code dst} (.mp4), stream-copying the HEVC. + * + * @return {@code true} if {@code dst} now holds a complete, playable MP4. On + * failure {@code dst} is left absent and the caller should keep + * {@code src} as the (still .ts) playable/shareable file. + */ + public static boolean remux(Context ctx, File src, File dst) { + final Context app = ctx.getApplicationContext(); + final File part = new File(dst.getAbsolutePath() + ".part"); + //noinspection ResultOfMethodCallIgnored + part.delete(); // clear any leftover from a previous interrupted run + + final CountDownLatch done = new CountDownLatch(1); + final AtomicBoolean ok = new AtomicBoolean(false); + final Transformer[] ref = new Transformer[1]; + + final HandlerThread ht = new HandlerThread("mp4-export"); + ht.start(); + final Handler h = new Handler(ht.getLooper()); + + h.post(() -> { + Transformer t = new Transformer.Builder(app) + // Use media3's in-app MP4 muxer, NOT the Android framework MediaMuxer: + // the framework muxer fails to write this onboard HEVC stream + // (stop() error -1007 ERROR_MALFORMED). The in-app muxer handles it. + .setMuxerFactory(new InAppMp4Muxer.Factory()) + .addListener(new Transformer.Listener() { + @Override + public void onCompleted(Composition c, ExportResult r) { + ok.set(true); + done.countDown(); + } + + @Override + public void onError(Composition c, ExportResult r, ExportException e) { + DebugLog.add("mp4 export failed (" + src.getName() + "): " + e.getMessage()); + done.countDown(); + } + }) + .build(); + ref[0] = t; + t.start(MediaItem.fromUri(Uri.fromFile(src)), part.getAbsolutePath()); + }); + + boolean finished = false; + try { + finished = done.await(TIMEOUT_MIN, TimeUnit.MINUTES); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + } + if (!finished) { + DebugLog.add("mp4 export timed out for " + src.getName()); + // cancel() must run on the Transformer's own looper; quitSafely() lets + // this queued message run before the looper stops. + h.post(() -> { try { if (ref[0] != null) ref[0].cancel(); } catch (Exception ignore) {} }); + } + ht.quitSafely(); + + boolean good = ok.get() && part.exists() && part.length() > 0 && part.renameTo(dst); + if (!good) { + //noinspection ResultOfMethodCallIgnored + part.delete(); + //noinspection ResultOfMethodCallIgnored + dst.delete(); + } + return good; + } +} diff --git a/app/src/main/java/com/rubyfpv/viewer/recordings/Recording.java b/app/src/main/java/com/rubyfpv/viewer/recordings/Recording.java new file mode 100644 index 0000000..2a3838b --- /dev/null +++ b/app/src/main/java/com/rubyfpv/viewer/recordings/Recording.java @@ -0,0 +1,41 @@ +package com.rubyfpv.viewer.recordings; + +import java.io.File; + +/** + * One onboard clip. Keyed by {@link #stem} (filename without extension) so the + * drone-side {@code .ts} and the downloaded copy describe the same item. + */ +public class Recording { + + public enum State { + ON_DRONE, // exists on the drone, not yet downloaded + DOWNLOADING, // SFTP/exec pull in progress + REMUXING, // post-download: remux .ts→.mp4 + thumbnail; labelled "Converting to MP4…" + READY, // local playable file present + FAILED // download error + } + + public final String stem; // e.g. rec_00h03m08s_4c3c + public String tsName; // e.g. rec_00h03m08s_4c3c.ts (null if drone copy gone) + public String osdName; // paired .osd on drone, or null + + public long remoteSize; // bytes on drone (0 if unknown / local-only) + public long mtimeEpoch; // seconds; drone mtime or local lastModified + + public State state = State.ON_DRONE; + public int progress; // 0..100 during download + public boolean onDrone; // still present on the SD card + + public android.net.Uri localUri; // published gallery item (Movies/RubyFPV) — store of record; null until published + public File localFile; // transient/fallback only: staged .mp4 (publish failed) or .ts (remux failed) + public long durationMs = -1; // best-effort; raw TS carries no duration header + + public Recording(String stem) { + this.stem = stem; + } + + public boolean isLocal() { + return localUri != null || (localFile != null && localFile.exists()); + } +} diff --git a/app/src/main/java/com/rubyfpv/viewer/recordings/RecordingsActivity.java b/app/src/main/java/com/rubyfpv/viewer/recordings/RecordingsActivity.java new file mode 100644 index 0000000..41b59be --- /dev/null +++ b/app/src/main/java/com/rubyfpv/viewer/recordings/RecordingsActivity.java @@ -0,0 +1,1011 @@ +package com.rubyfpv.viewer.recordings; + +import android.content.ClipData; +import android.content.ClipboardManager; +import android.content.Context; +import android.content.Intent; +import android.content.SharedPreferences; +import android.content.pm.PackageManager; +import android.content.res.ColorStateList; +import android.net.Uri; +import android.os.Build; +import android.os.Bundle; +import android.os.Handler; +import android.os.Looper; +import android.util.TypedValue; +import android.view.Menu; +import android.view.MenuItem; +import android.view.View; +import android.widget.ScrollView; +import android.widget.TextView; +import android.widget.Toast; + +import androidx.activity.OnBackPressedCallback; +import androidx.activity.result.ActivityResultLauncher; +import androidx.activity.result.contract.ActivityResultContracts; +import androidx.annotation.Nullable; +import androidx.appcompat.app.AppCompatActivity; +import androidx.core.content.ContextCompat; +import androidx.core.content.FileProvider; +import androidx.core.graphics.Insets; +import androidx.core.view.ViewCompat; +import androidx.core.view.WindowInsetsCompat; +import androidx.recyclerview.widget.GridLayoutManager; +import androidx.recyclerview.widget.LinearLayoutManager; +import androidx.recyclerview.widget.RecyclerView; +import androidx.swiperefreshlayout.widget.SwipeRefreshLayout; + +import com.google.android.material.appbar.MaterialToolbar; +import com.google.android.material.button.MaterialButton; +import com.google.android.material.dialog.MaterialAlertDialogBuilder; +import com.google.android.material.snackbar.Snackbar; +import com.google.android.material.textfield.TextInputEditText; +import com.rubyfpv.viewer.MainActivity; +import com.rubyfpv.viewer.R; +import com.rubyfpv.viewer.player.PlaybackActivity; + +import java.io.File; +import java.util.ArrayList; +import java.util.Collections; +import java.util.HashSet; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; + +/** + * Primary screen: browse, download, preview and share onboard drone recordings. + * + *

All drone I/O runs on {@link #io} (a single serial thread so the dropbear + * session is never used concurrently); CPU work (remux, thumbnail) runs on + * {@link #work}. UI updates are posted back to {@link #ui}.

+ */ +public class RecordingsActivity extends AppCompatActivity + implements RecordingsAdapter.Listener { + + private static final String PREFS = "ruby_conn"; + + private final Handler ui = new Handler(Looper.getMainLooper()); + private final ExecutorService io = Executors.newSingleThreadExecutor(); + private final ExecutorService work = Executors.newSingleThreadExecutor(); + + private final Map byStem = new LinkedHashMap<>(); + private final Set prefetching = Collections.synchronizedSet(new HashSet<>()); + private RecordingsAdapter adapter; + + private MaterialToolbar toolbar; + private RecyclerView list; + private View statusDot; + private TextView statusText; + private MaterialButton btnConnect; + private SwipeRefreshLayout swipe; + private View emptyView; + private TextView emptyTitle, emptyBody; + private View loading; + + private RubyShell shell; + private WifiJoiner wifi; + private volatile boolean connected; + private boolean compact; + private File dir; + private File thumbsDir; + private File cacheDir; + + /** Read-media permission so the gallery scan can see clips a PRIOR install created. */ + private final ActivityResultLauncher readMediaPerm = + registerForActivityResult(new ActivityResultContracts.RequestPermission(), granted -> { + DebugLog.add("read-media permission " + (granted ? "granted" : "denied")); + // Items the current install created are queryable regardless; rescan either way. + loadLocal(); + }); + + @Override + protected void onCreate(@Nullable Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + setContentView(R.layout.activity_recordings); + + File base = getExternalFilesDir(null); + if (base == null) base = getFilesDir(); // external storage unavailable + dir = new File(base, "recordings"); + //noinspection ResultOfMethodCallIgnored + dir.mkdirs(); + thumbsDir = new File(getFilesDir(), "thumbs"); + //noinspection ResultOfMethodCallIgnored + thumbsDir.mkdirs(); + cacheDir = getCacheDir(); + wifi = new WifiJoiner(this); + + SharedPreferences prefs = getSharedPreferences(PREFS, Context.MODE_PRIVATE); + compact = prefs.getBoolean("compact", false); + + toolbar = findViewById(R.id.toolbar); + toolbar.setSubtitle("v" + com.rubyfpv.viewer.BuildConfig.VERSION_NAME + + " (" + com.rubyfpv.viewer.BuildConfig.VERSION_CODE + ")"); + toolbar.setOnMenuItemClickListener(this::onMenu); + + statusDot = findViewById(R.id.status_dot); + statusText = findViewById(R.id.status_text); + btnConnect = findViewById(R.id.btn_connect); + swipe = findViewById(R.id.swipe); + emptyView = findViewById(R.id.empty_view); + emptyTitle = findViewById(R.id.empty_title); + emptyBody = findViewById(R.id.empty_body); + loading = findViewById(R.id.loading); + + adapter = new RecordingsAdapter(this); + adapter.setCompact(compact); + list = findViewById(R.id.list); + list.setAdapter(adapter); + applyLayoutManager(); + updateLayoutIcon(); + applyInsets(); + + swipe.setColorSchemeColors(ContextCompat.getColor(this, R.color.ruby)); + swipe.setProgressBackgroundColorSchemeColor(ContextCompat.getColor(this, R.color.surface)); + swipe.setOnRefreshListener(() -> { + if (connected) refresh(); + else { swipe.setRefreshing(false); connect(); } + }); + + btnConnect.setOnClickListener(v -> { + if (connected) disconnect(); + else connect(); + }); + + // Preload any thumbnails we cached on previous sessions. + work.execute(() -> { + Thumbs.loadDiskInto(thumbsDir); + ui.post(() -> adapter.notifyDataSetChanged()); + }); + + // Back exits multi-select before leaving the screen. + getOnBackPressedDispatcher().addCallback(this, new OnBackPressedCallback(true) { + @Override + public void handleOnBackPressed() { + if (adapter.isSelectionMode()) { + adapter.exitSelection(); + } else { + setEnabled(false); + getOnBackPressedDispatcher().onBackPressed(); + } + } + }); + + loadLocal(); + requestReadMediaIfNeeded(); + setStatus(StatusKind.OFFLINE, getString(R.string.status_disconnected)); + } + + /** + * Request the read-media permission (non-blocking) so the gallery scan can list + * clips created by a PRIOR install. If denied we still proceed: clips this install + * created remain queryable without the permission. + */ + private void requestReadMediaIfNeeded() { + final String perm = Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU + ? android.Manifest.permission.READ_MEDIA_VIDEO + : android.Manifest.permission.READ_EXTERNAL_STORAGE; + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q + && ContextCompat.checkSelfPermission(this, perm) + != PackageManager.PERMISSION_GRANTED) { + readMediaPerm.launch(perm); + } + } + + /** Pad the app bar for the status bar and the list for the nav bar (edge-to-edge). */ + private void applyInsets() { + View appbar = findViewById(R.id.appbar); + ViewCompat.setOnApplyWindowInsetsListener(appbar, (v, insets) -> { + Insets bars = insets.getInsets(WindowInsetsCompat.Type.systemBars()); + v.setPadding(v.getPaddingLeft(), bars.top, v.getPaddingRight(), v.getPaddingBottom()); + return insets; + }); + final int basePad = list.getPaddingBottom(); + ViewCompat.setOnApplyWindowInsetsListener(list, (v, insets) -> { + Insets bars = insets.getInsets(WindowInsetsCompat.Type.systemBars()); + v.setPadding(v.getPaddingLeft(), v.getPaddingTop(), v.getPaddingRight(), + basePad + bars.bottom); + return insets; + }); + } + + private void applyLayoutManager() { + list.setLayoutManager(compact + ? new GridLayoutManager(this, 2) + : new LinearLayoutManager(this)); + } + + private void updateLayoutIcon() { + if (toolbar.getMenu() != null && toolbar.getMenu().findItem(R.id.menu_layout) != null) { + toolbar.getMenu().findItem(R.id.menu_layout) + .setIcon(compact ? R.drawable.ic_list : R.drawable.ic_grid); + } + } + + // ── Menu ──────────────────────────────────────────────────────────── + + private boolean onMenu(android.view.MenuItem item) { + int id = item.getItemId(); + if (id == R.id.menu_layout) { + compact = !compact; + getSharedPreferences(PREFS, Context.MODE_PRIVATE).edit() + .putBoolean("compact", compact).apply(); + applyLayoutManager(); + adapter.setCompact(compact); + updateLayoutIcon(); + return true; + } else if (id == R.id.menu_refresh) { + if (connected) refresh(); else connect(); + return true; + } else if (id == R.id.menu_live) { + startActivity(new Intent(this, MainActivity.class)); + return true; + } else if (id == R.id.menu_return_fpv) { + confirmReturnToFpv(); + return true; + } else if (id == R.id.menu_connection) { + showConnectionDialog(); + return true; + } else if (id == R.id.menu_diagnostics) { + showDiagnosticsDialog(); + return true; + } else if (id == R.id.menu_delete) { + bulkDelete(adapter.selectedItems()); + return true; + } + return false; + } + + /** Show the in-app {@link DebugLog} ring buffer so the thumbnail/transfer pipeline + * can be inspected on-device (no adb), with a Copy button to paste it back out. */ + private void showDiagnosticsDialog() { + final String dump = DebugLog.dump(); + + TextView tv = new TextView(this); + tv.setText(dump); + tv.setTextIsSelectable(true); + tv.setTypeface(android.graphics.Typeface.MONOSPACE); + tv.setTextSize(TypedValue.COMPLEX_UNIT_SP, 11); + int pad = Math.round(16 * getResources().getDisplayMetrics().density); + tv.setPadding(pad, pad, pad, pad); + + ScrollView sv = new ScrollView(this); + sv.addView(tv); + + new MaterialAlertDialogBuilder(this) + .setTitle("Diagnostics") + .setView(sv) + .setNeutralButton("Clear", (d, w) -> { + DebugLog.clear(); + Toast.makeText(this, "Diagnostics cleared", Toast.LENGTH_SHORT).show(); + }) + .setNegativeButton("Copy", (d, w) -> { + ClipboardManager cb = + (ClipboardManager) getSystemService(Context.CLIPBOARD_SERVICE); + if (cb != null) { + cb.setPrimaryClip(ClipData.newPlainText("RubyFPV diagnostics", dump)); + Toast.makeText(this, "Copied to clipboard", Toast.LENGTH_SHORT).show(); + } + }) + .setPositiveButton("Close", null) + .show(); + } + + // ── Return to FPV ─────────────────────────────────────────────────── + + /** + * Reboot the drone out of phone-transfer mode, back into normal FPV. Distinct from + * {@link #disconnect()} (which only drops our SSH session): leaving AP mode requires a + * drone reboot — {@code ap_mode.sh stop} == {@code reboot} — so this is a deliberate, + * confirmed action. The AP (and our link) drop as the drone goes down; that's expected. + */ + private void confirmReturnToFpv() { + if (!connected || shell == null) { + Toast.makeText(this, R.string.return_fpv_need_connection, Toast.LENGTH_SHORT).show(); + return; + } + new MaterialAlertDialogBuilder(this) + .setTitle(R.string.return_fpv_title) + .setMessage(R.string.return_fpv_message) + .setNegativeButton(R.string.cancel, null) + .setPositiveButton(R.string.return_fpv_confirm, (d, w) -> doReturnToFpv()) + .show(); + } + + private void doReturnToFpv() { + final RubyShell s = shell; + if (s == null) return; + setStatus(StatusKind.BUSY, getString(R.string.status_return_fpv)); + io.execute(() -> { + try { + s.returnToFpv(); + } catch (Exception ignored) { + // The reboot drops the link mid-command — that's success, not an error. + } + ui.post(() -> { + Toast.makeText(this, R.string.status_return_fpv, Toast.LENGTH_LONG).show(); + disconnect(); // tear down our side; the drone is rebooting into FPV + }); + }); + } + + // ── Connection ────────────────────────────────────────────────────── + + private void connect() { + if (connected) return; + setStatus(StatusKind.BUSY, getString(R.string.status_connecting)); + btnConnect.setEnabled(false); + RubyShell.Config cfg = readConfig(); + io.execute(() -> { + RubyShell s = new RubyShell(cfg); + try { + s.connect(); + List remote = s.listRecordings(); + ui.post(() -> { + shell = s; + connected = true; + btnConnect.setEnabled(true); + btnConnect.setText(R.string.disconnect); + btnConnect.setIconResource(R.drawable.ic_check_circle); + setStatus(StatusKind.ONLINE, getString(R.string.status_connected, cfg.host)); + mergeRemote(remote); + }); + } catch (Exception e) { + s.close(); + ui.post(() -> { + btnConnect.setEnabled(true); + setStatus(StatusKind.OFFLINE, getString(R.string.status_error)); + Snackbar.make(swipe, getString(R.string.status_error) + ": " + e.getMessage(), + Snackbar.LENGTH_LONG) + .setAction(R.string.join_wifi, v -> WifiJoiner.openWifiPanel(this)) + .show(); + refreshUi(); + }); + } + }); + } + + private void disconnect() { + connected = false; + btnConnect.setText(R.string.connect); + btnConnect.setIconResource(R.drawable.ic_wifi); + setStatus(StatusKind.OFFLINE, getString(R.string.status_disconnected)); + io.execute(() -> { + if (shell != null) { shell.close(); shell = null; } + }); + wifi.release(); + // Drop drone-only items, keep local ones. + ui.post(() -> { + List drop = new ArrayList<>(); + for (Recording r : byStem.values()) { + r.onDrone = false; + if (!r.isLocal()) drop.add(r.stem); + } + for (String k : drop) byStem.remove(k); + refreshUi(); + }); + } + + private void refresh() { + if (!connected || shell == null) return; + swipe.setRefreshing(true); + io.execute(() -> { + try { + List remote = shell.listRecordings(); + ui.post(() -> { mergeRemote(remote); swipe.setRefreshing(false); }); + } catch (Exception e) { + ui.post(() -> { + swipe.setRefreshing(false); + Toast.makeText(this, "Refresh failed: " + e.getMessage(), + Toast.LENGTH_SHORT).show(); + }); + } + }); + } + + /** Merge a fresh drone listing into the model, preserving local state. */ + private void mergeRemote(List remote) { + for (Recording r : byStem.values()) r.onDrone = false; + for (Recording rr : remote) { + Recording cur = byStem.get(rr.stem); + if (cur == null) { + byStem.put(rr.stem, rr); + } else { + cur.tsName = rr.tsName; + cur.osdName = rr.osdName; + cur.remoteSize = rr.remoteSize; + if (cur.mtimeEpoch <= 0) cur.mtimeEpoch = rr.mtimeEpoch; + cur.onDrone = true; + } + } + // purge entries that are neither on the drone nor downloaded + List drop = new ArrayList<>(); + for (Recording r : byStem.values()) { + if (!r.onDrone && !r.isLocal()) drop.add(r.stem); + } + for (String k : drop) byStem.remove(k); + refreshUi(); + prefetchThumbs(); + } + + /** Fetch a small .ts prefix for any clip lacking a cached thumbnail. */ + private void prefetchThumbs() { + if (!connected || shell == null) return; + for (Recording r : byStem.values()) { + if (r.tsName == null) continue; + if (Thumbs.CACHE.get(r.stem) != null) continue; + prefetchThumb(r); + } + } + + private void prefetchThumb(Recording r) { + if (!prefetching.add(r.stem)) return; // already queued + io.execute(() -> { + File part = new File(cacheDir, r.stem + ".tspart"); + try { + if (shell == null || Thumbs.CACHE.get(r.stem) != null) return; + shell.downloadPrefix(r.tsName, 4 * 1024 * 1024, part); + Thumbs.Meta m = Thumbs.extract(getApplicationContext(), part, r.stem); // bitmap only + if (m.bitmap != null) { + Thumbs.saveDisk(thumbsDir, r.stem, m.bitmap); + ui.post(() -> adapter.update(r)); + } + } catch (Exception ignored) { + // best-effort; clip just shows the placeholder + } finally { + //noinspection ResultOfMethodCallIgnored + part.delete(); + prefetching.remove(r.stem); + } + }); + } + + // ── Local scan ────────────────────────────────────────────────────── + + private void loadLocal() { + // 1) The gallery (Movies/RubyFPV) is the store of record — scan it first. + List items = GalleryStore.query(getApplicationContext()); + for (GalleryStore.GalleryItem g : items) { + if (g.stem == null || g.stem.isEmpty()) continue; + Recording r = byStem.get(g.stem); + if (r == null) { r = new Recording(g.stem); byStem.put(g.stem, r); } + r.localUri = g.uri; + r.localFile = null; + if (g.durationMs > 0) r.durationMs = g.durationMs; + if (g.mtimeEpochSec > 0) r.mtimeEpoch = g.mtimeEpochSec; + r.state = Recording.State.READY; + } + for (Recording r : byStem.values()) { + if (r.state == Recording.State.READY && r.localUri != null) extractMeta(r); + } + refreshUi(); + + // 2) Best-effort migration of any leftover private files (older builds / + // interrupted publish). Normally zero on this device; never fatal. + migratePrivateLeftovers(); + } + + /** + * Move any clips still sitting in the app-private {@code dir} (from older builds + * or a publish interrupted mid-write) into the gallery, then delete the private + * copy. Purges {@code .part} and 0-byte leftovers. Runs scans on {@link #work}. + */ + private void migratePrivateLeftovers() { + File[] files = dir.listFiles(); + if (files == null) return; + for (File f : files) { + String name = f.getName(); + if (name.endsWith(".part")) { + //noinspection ResultOfMethodCallIgnored + f.delete(); + continue; + } + boolean mp4 = name.endsWith(".mp4"); + boolean ts = name.endsWith(".ts"); + if (!mp4 && !ts) continue; + if (f.length() == 0) { + //noinspection ResultOfMethodCallIgnored + f.delete(); + continue; + } + String stem = name.substring(0, name.lastIndexOf('.')); + Recording existing = byStem.get(stem); + // Already published to the gallery? Drop the private dup. + if (existing != null && existing.localUri != null) { + //noinspection ResultOfMethodCallIgnored + f.delete(); + continue; + } + Recording r = existing; + if (r == null) { r = new Recording(stem); byStem.put(stem, r); } + // Prefer migrating the .mp4 if both exist; the .ts is handled below. + if (mp4) { + r.localFile = f; + r.state = Recording.State.REMUXING; + final Recording fr = r; + final File ff = f; + ui.post(() -> adapter.update(fr)); + work.execute(() -> publishAndFinalize(fr, ff)); + } else { // .ts and no sibling .mp4 being migrated + if (new File(dir, stem + ".mp4").exists()) { + //noinspection ResultOfMethodCallIgnored + f.delete(); // .mp4 sibling will be migrated instead + continue; + } + r.localFile = f; + convertLocal(r); + } + } + refreshUi(); + } + + /** + * Publish an already-converted private {@code mp4} into the gallery, delete the + * private copy on success, extract the thumbnail and mark READY. On failure keep + * the private {@code mp4} as the playable/shareable fallback. Runs on {@link #work}. + */ + private void publishAndFinalize(Recording r, File mp4) { + Uri uri = GalleryStore.publish(getApplicationContext(), mp4, r.stem + ".mp4"); + if (uri != null) { + //noinspection ResultOfMethodCallIgnored + mp4.delete(); + Thumbs.Meta m = Thumbs.extract(getApplicationContext(), uri, r.stem); + if (m.bitmap != null) Thumbs.saveDisk(thumbsDir, r.stem, m.bitmap); + final long durMs = m.durationMs; + ui.post(() -> { + r.localUri = uri; + r.localFile = null; + if (durMs > 0) r.durationMs = durMs; + r.state = Recording.State.READY; + adapter.update(r); + }); + } else { + Thumbs.Meta m = Thumbs.extract(getApplicationContext(), mp4, r.stem); + if (m.bitmap != null) Thumbs.saveDisk(thumbsDir, r.stem, m.bitmap); + final long durMs = m.durationMs; + ui.post(() -> { + r.localFile = mp4; + if (durMs > 0) r.durationMs = durMs; + r.state = Recording.State.READY; + adapter.update(r); + }); + } + } + + private void extractMeta(Recording r) { + if (Thumbs.CACHE.get(r.stem) != null && r.durationMs > 0) return; + final Uri uri = r.localUri; + final File f = r.localFile; + if (uri == null && f == null) return; + work.execute(() -> { + Thumbs.Meta m = uri != null + ? Thumbs.extract(getApplicationContext(), uri, r.stem) + : Thumbs.extract(getApplicationContext(), f, r.stem); + if (m.bitmap != null) Thumbs.saveDisk(thumbsDir, r.stem, m.bitmap); + ui.post(() -> { + if (m.durationMs > 0) r.durationMs = m.durationMs; + adapter.update(r); + }); + }); + } + + // ── Adapter callbacks ─────────────────────────────────────────────── + + @Override + public void onPrimary(Recording r) { + // Download (or retry) + if (!connected || shell == null) { + Snackbar.make(swipe, R.string.empty_disconnected_title, Snackbar.LENGTH_SHORT) + .setAction(R.string.connect, v -> connect()).show(); + return; + } + if (r.tsName == null) return; + r.state = Recording.State.DOWNLOADING; + r.progress = 0; + adapter.update(r); + + io.execute(() -> { + File ts = new File(dir, r.tsName); + try { + shell.download(r.tsName, r.remoteSize, ts, (t, total) -> { + int pct = total > 0 ? (int) (t * 100 / total) : 0; + if (pct != r.progress) { + r.progress = pct; + ui.post(() -> adapter.update(r)); + } + }); + if (r.osdName != null) { + try { + shell.download(r.osdName, 0, new File(dir, r.osdName), null); + } catch (Exception ignore) { /* OSD sidecar is optional */ } + } + ui.post(() -> { r.state = Recording.State.REMUXING; adapter.update(r); }); + finishDownload(r, ts); + } catch (Exception e) { + //noinspection ResultOfMethodCallIgnored + ts.delete(); + ui.post(() -> { + r.state = Recording.State.FAILED; + adapter.update(r); + Toast.makeText(this, "Download failed: " + e.getMessage(), + Toast.LENGTH_SHORT).show(); + }); + } + }); + } + + /** + * Finalise a freshly-downloaded clip: remux the {@code .ts} into a share-ready + * {@code .mp4} (social apps reject MPEG-TS), then extract the thumbnail. The + * caller has already moved the row to {@link Recording.State#REMUXING}. + */ + private void finishDownload(Recording r, File ts) { + work.execute(() -> remuxAndFinalize(r, ts)); + } + + /** + * Upgrade a clip that is still backed locally by a {@code .ts} (downloaded by + * an older build) to a shareable {@code .mp4}, in the background. + */ + private void convertLocal(Recording r) { + final File ts = r.localFile; + ui.post(() -> { r.state = Recording.State.REMUXING; adapter.update(r); }); + work.execute(() -> remuxAndFinalize(r, ts)); + } + + /** + * Remux {@code ts} → a staged {@code stem.mp4} (HEVC stream-copy), then publish it + * into the gallery ({@code Movies/RubyFPV}) as the store of record and delete both + * staged private files. Extracts the thumbnail and marks READY. + * + *

Fallbacks (private file kept, {@code localUri} stays null):

+ *
    + *
  • publish fails → keep staged {@code .mp4} as playable/shareable;
  • + *
  • remux fails → keep the {@code .ts} as playable/shareable.
  • + *
+ * Runs on {@link #work}. + */ + private void remuxAndFinalize(Recording r, File ts) { + File mp4 = new File(dir, r.stem + ".mp4"); + if (Mp4Export.remux(getApplicationContext(), ts, mp4)) { + //noinspection ResultOfMethodCallIgnored + ts.delete(); + Uri uri = GalleryStore.publish(getApplicationContext(), mp4, r.stem + ".mp4"); + if (uri != null) { + //noinspection ResultOfMethodCallIgnored + mp4.delete(); + Thumbs.Meta m = Thumbs.extract(getApplicationContext(), uri, r.stem); + if (m.bitmap != null) Thumbs.saveDisk(thumbsDir, r.stem, m.bitmap); + final long durMs = m.durationMs; + ui.post(() -> { + r.localUri = uri; + r.localFile = null; + if (durMs > 0) r.durationMs = durMs; + r.state = Recording.State.READY; + adapter.update(r); + }); + return; + } + // Publish failed — keep the staged .mp4 as the fallback. + DebugLog.add("publish returned null, keeping private mp4 for " + r.stem); + finalizeFallback(r, mp4); + return; + } + // Remux failed — keep the .ts as the playable/shareable fallback. + finalizeFallback(r, ts); + } + + /** Mark READY backed by a private file (publish/remux fallback path). */ + private void finalizeFallback(Recording r, File pf) { + Thumbs.Meta m = Thumbs.extract(getApplicationContext(), pf, r.stem); + if (m.bitmap != null) Thumbs.saveDisk(thumbsDir, r.stem, m.bitmap); + final long durMs = m.durationMs; + ui.post(() -> { + r.localFile = pf; + r.localUri = null; + if (durMs > 0) r.durationMs = durMs; + r.state = Recording.State.READY; + adapter.update(r); + }); + } + + @Override + public void onPlay(Recording r) { + Intent i = new Intent(this, PlaybackActivity.class); + if (r.localUri != null) { + i.putExtra(PlaybackActivity.EXTRA_URI, r.localUri.toString()); + } else if (r.localFile != null && r.localFile.exists()) { + i.putExtra(PlaybackActivity.EXTRA_PATH, r.localFile.getAbsolutePath()); + } else { + return; + } + i.putExtra(PlaybackActivity.EXTRA_TITLE, r.stem); + startActivity(i); + } + + @Override + public void onShare(Recording r) { + try { + Uri uri; + String type; + if (r.localUri != null) { + uri = r.localUri; // gallery item — share directly + type = "video/mp4"; + } else if (r.localFile != null && r.localFile.exists()) { + uri = FileProvider.getUriForFile( + this, getPackageName() + ".fileprovider", r.localFile); + type = mimeFor(r.localFile); + } else { + return; + } + Intent send = new Intent(Intent.ACTION_SEND); + send.setType(type); + send.putExtra(Intent.EXTRA_STREAM, uri); + send.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION); + startActivity(Intent.createChooser(send, getString(R.string.share_title))); + } catch (Exception e) { + Toast.makeText(this, R.string.toast_share_failed, Toast.LENGTH_SHORT).show(); + } + } + + @Override + public void onDelete(Recording r) { + boolean onPhone = r.isLocal(); + boolean onDrone = r.onDrone && connected; + if (onPhone && onDrone) { + // Let the user pick which copy/copies to remove. + CharSequence[] opts = { + getString(R.string.delete_phone_only), + getString(R.string.delete_sd_only), + getString(R.string.delete_both), + }; + new MaterialAlertDialogBuilder(this) + .setTitle(R.string.delete_title) + .setItems(opts, (d, which) -> + confirmDelete(r, which == 0 || which == 2, which == 1 || which == 2)) + .setNegativeButton(R.string.cancel, null) + .show(); + } else { + confirmDelete(r, onPhone, onDrone); + } + } + + private void confirmDelete(Recording r, boolean phone, boolean drone) { + if (!phone && !drone) return; + String msg; + if (phone && drone) msg = getString(R.string.delete_both_body, r.stem); + else if (drone) msg = getString(R.string.delete_drone_body, r.stem); + else msg = getString(R.string.delete_phone_body, r.stem); + new MaterialAlertDialogBuilder(this) + .setTitle(R.string.delete_title) + .setMessage(msg) + .setNegativeButton(R.string.cancel, null) + .setPositiveButton(R.string.delete_confirm, (d, w) -> { + removeRecording(r, phone, drone); + refreshUi(); + }) + .show(); + } + + // ── Multi-select ──────────────────────────────────────────────────── + + @Override + public void onSelectionChanged(int count) { + if (count > 0) { + toolbar.setNavigationIcon(R.drawable.ic_arrow_back); + toolbar.setNavigationOnClickListener(v -> adapter.exitSelection()); + toolbar.setTitle(getString(R.string.selected_count, count)); + toolbar.setSubtitle(null); + } else { + toolbar.setNavigationIcon(null); + toolbar.setNavigationOnClickListener(null); + toolbar.setTitle(R.string.recordings_title); + toolbar.setSubtitle("v" + com.rubyfpv.viewer.BuildConfig.VERSION_NAME + + " (" + com.rubyfpv.viewer.BuildConfig.VERSION_CODE + ")"); + } + Menu m = toolbar.getMenu(); + if (m != null) { + boolean selecting = count > 0; + int[] normal = { R.id.menu_layout, R.id.menu_refresh, R.id.menu_live, + R.id.menu_connection, R.id.menu_diagnostics }; + for (int id : normal) { + MenuItem it = m.findItem(id); + if (it != null) it.setVisible(!selecting); + } + MenuItem del = m.findItem(R.id.menu_delete); + if (del != null) del.setVisible(selecting); + } + } + + private void bulkDelete(java.util.List sel) { + if (sel == null || sel.isEmpty()) return; + boolean anyPhone = false, anyDrone = false; + for (Recording r : sel) { + if (r.isLocal()) anyPhone = true; + if (r.onDrone && connected) anyDrone = true; + } + if (!anyPhone && !anyDrone) return; + if (anyPhone && anyDrone) { + CharSequence[] opts = { + getString(R.string.delete_phone_only), + getString(R.string.delete_sd_only), + getString(R.string.delete_both), + }; + new MaterialAlertDialogBuilder(this) + .setTitle(getString(R.string.delete_selected_title, sel.size())) + .setItems(opts, (d, which) -> + confirmBulk(sel, which == 0 || which == 2, which == 1 || which == 2)) + .setNegativeButton(R.string.cancel, null) + .show(); + } else { + confirmBulk(sel, anyPhone, anyDrone); + } + } + + private void confirmBulk(java.util.List sel, boolean phone, boolean drone) { + String where = (phone && drone) ? getString(R.string.delete_where_both) + : drone ? getString(R.string.delete_where_sd) + : getString(R.string.delete_where_phone); + new MaterialAlertDialogBuilder(this) + .setTitle(getString(R.string.delete_selected_title, sel.size())) + .setMessage(getString(R.string.delete_selected_body, sel.size(), where)) + .setNegativeButton(R.string.cancel, null) + .setPositiveButton(R.string.delete_confirm, (d, w) -> { + for (Recording r : new ArrayList<>(sel)) { + removeRecording(r, phone && r.isLocal(), + drone && r.onDrone && connected); + } + adapter.exitSelection(); + refreshUi(); + }) + .show(); + } + + /** Remove one clip from the chosen location(s); does not touch the UI. */ + private void removeRecording(Recording r, boolean phone, boolean drone) { + if (phone) { + deleteLocal(r); + Thumbs.CACHE.remove(r.stem); + File thumb = new File(thumbsDir, r.stem + ".jpg"); + if (thumb.exists()) //noinspection ResultOfMethodCallIgnored + thumb.delete(); + } + if (drone && shell != null) { + final String ts = r.tsName, osd = r.osdName; + io.execute(() -> { + try { shell.delete(ts, osd); } catch (Exception ignored) {} + }); + r.onDrone = false; + } + if (!r.isLocal() && !r.onDrone) { + byStem.remove(r.stem); + } else if (r.isLocal()) { + r.state = Recording.State.READY; + } else { + r.state = Recording.State.ON_DRONE; + } + } + + private static String mimeFor(File f) { + return f != null && f.getName().endsWith(".ts") ? "video/mp2t" : "video/mp4"; + } + + private void deleteLocal(Recording r) { + // Primary store of record: the gallery item. + if (r.localUri != null) { + final Uri uri = r.localUri; + work.execute(() -> GalleryStore.delete(getApplicationContext(), uri)); + r.localUri = null; + } + // Any private staged/fallback files. + File[] victims = { + r.localFile, + new File(dir, r.stem + ".mp4"), + new File(dir, r.stem + ".ts"), + new File(dir, r.stem + ".osd") + }; + for (File f : victims) { + if (f != null && f.exists()) //noinspection ResultOfMethodCallIgnored + f.delete(); + } + r.localFile = null; + } + + // ── UI helpers ────────────────────────────────────────────────────── + + private void refreshUi() { + List sorted = new ArrayList<>(byStem.values()); + Collections.sort(sorted, (a, b) -> Long.compare(b.mtimeEpoch, a.mtimeEpoch)); + adapter.submit(sorted); + loading.setVisibility(View.GONE); + + if (sorted.isEmpty()) { + emptyView.setVisibility(View.VISIBLE); + if (connected) { + emptyTitle.setText(R.string.empty_none_title); + emptyBody.setText(R.string.empty_none_body); + } else { + emptyTitle.setText(R.string.empty_disconnected_title); + emptyBody.setText(R.string.empty_disconnected_body); + } + } else { + emptyView.setVisibility(View.GONE); + } + } + + private enum StatusKind { OFFLINE, BUSY, ONLINE } + + private void setStatus(StatusKind kind, String text) { + int colorRes; + switch (kind) { + case ONLINE: colorRes = R.color.status_online; break; + case BUSY: colorRes = R.color.status_busy; break; + default: colorRes = R.color.status_offline; break; + } + statusDot.setBackgroundTintList( + ColorStateList.valueOf(ContextCompat.getColor(this, colorRes))); + statusText.setText(text); + } + + // ── Connection settings ───────────────────────────────────────────── + + private RubyShell.Config readConfig() { + SharedPreferences p = getSharedPreferences(PREFS, Context.MODE_PRIVATE); + RubyShell.Config c = new RubyShell.Config(); + c.host = p.getString("host", "192.168.4.1"); + c.port = p.getInt("port", 22); + c.user = p.getString("user", "root"); + c.pass = p.getString("pass", "12345"); + return c; + } + + private void showConnectionDialog() { + SharedPreferences p = getSharedPreferences(PREFS, Context.MODE_PRIVATE); + View v = getLayoutInflater().inflate(R.layout.dialog_connection, null); + TextInputEditText host = v.findViewById(R.id.in_host); + TextInputEditText user = v.findViewById(R.id.in_user); + TextInputEditText pass = v.findViewById(R.id.in_pass); + TextInputEditText ssid = v.findViewById(R.id.in_ssid); + TextInputEditText psk = v.findViewById(R.id.in_psk); + host.setText(p.getString("host", "192.168.4.1")); + user.setText(p.getString("user", "root")); + pass.setText(p.getString("pass", "12345")); + ssid.setText(p.getString("ssid", "")); + psk.setText(p.getString("psk", "")); + + new MaterialAlertDialogBuilder(this) + .setTitle(R.string.settings_title) + .setView(v) + .setNegativeButton(R.string.cancel, null) + .setPositiveButton(R.string.settings_save, (d, w) -> { + p.edit() + .putString("host", text(host, "192.168.4.1")) + .putString("user", text(user, "root")) + .putString("pass", text(pass, "12345")) + .putString("ssid", text(ssid, "")) + .putString("psk", text(psk, "")) + .apply(); + Toast.makeText(this, R.string.settings_save, Toast.LENGTH_SHORT).show(); + }) + .show(); + } + + private static String text(TextInputEditText e, String def) { + CharSequence c = e.getText(); + String s = c == null ? "" : c.toString().trim(); + return s.isEmpty() ? def : s; + } + + // ── Lifecycle ─────────────────────────────────────────────────────── + + @Override + protected void onDestroy() { + super.onDestroy(); + io.execute(() -> { if (shell != null) shell.close(); }); + wifi.release(); + io.shutdown(); + work.shutdown(); + } +} diff --git a/app/src/main/java/com/rubyfpv/viewer/recordings/RecordingsAdapter.java b/app/src/main/java/com/rubyfpv/viewer/recordings/RecordingsAdapter.java new file mode 100644 index 0000000..5090229 --- /dev/null +++ b/app/src/main/java/com/rubyfpv/viewer/recordings/RecordingsAdapter.java @@ -0,0 +1,273 @@ +package com.rubyfpv.viewer.recordings; + +import android.graphics.Bitmap; +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; +import android.widget.ImageView; +import android.widget.TextView; + +import androidx.annotation.NonNull; +import androidx.core.content.ContextCompat; +import androidx.recyclerview.widget.RecyclerView; + +import com.google.android.material.button.MaterialButton; +import com.google.android.material.card.MaterialCardView; +import com.google.android.material.progressindicator.LinearProgressIndicator; +import com.rubyfpv.viewer.R; + +import java.util.ArrayList; +import java.util.HashSet; +import java.util.List; +import java.util.Set; + +public class RecordingsAdapter extends RecyclerView.Adapter { + + private static final int TYPE_LARGE = 0; + private static final int TYPE_COMPACT = 1; + + public interface Listener { + void onPrimary(Recording r); // download / retry + void onPlay(Recording r); + void onShare(Recording r); + void onDelete(Recording r); + void onSelectionChanged(int count); // 0 = selection mode off + } + + private final List items = new ArrayList<>(); + private final Set selected = new HashSet<>(); + private final Listener listener; + private boolean compact; + private boolean selectionMode; + + public RecordingsAdapter(Listener listener) { + this.listener = listener; + setHasStableIds(true); + } + + public void submit(List next) { + items.clear(); + items.addAll(next); + // Drop selected stems that no longer exist (e.g. just deleted). + if (!selected.isEmpty()) { + Set present = new HashSet<>(); + for (Recording r : items) present.add(r.stem); + if (selected.retainAll(present) && selected.isEmpty()) { + selectionMode = false; + listener.onSelectionChanged(0); + } + } + notifyDataSetChanged(); + } + + public void update(Recording r) { + int i = items.indexOf(r); + if (i >= 0) notifyItemChanged(i); + } + + public void setCompact(boolean c) { + if (compact != c) { + compact = c; + notifyDataSetChanged(); + } + } + + public boolean isCompact() { + return compact; + } + + // ── Multi-select ──────────────────────────────────────────────────── + + public boolean isSelectionMode() { + return selectionMode; + } + + public List selectedItems() { + List out = new ArrayList<>(); + for (Recording r : items) if (selected.contains(r.stem)) out.add(r); + return out; + } + + public void exitSelection() { + if (!selectionMode && selected.isEmpty()) return; + selectionMode = false; + selected.clear(); + notifyDataSetChanged(); + listener.onSelectionChanged(0); + } + + private void enterSelection(Recording r) { + selectionMode = true; + selected.add(r.stem); + notifyDataSetChanged(); + listener.onSelectionChanged(selected.size()); + } + + private void toggle(Recording r) { + if (!selected.remove(r.stem)) selected.add(r.stem); + if (selected.isEmpty()) selectionMode = false; + notifyDataSetChanged(); + listener.onSelectionChanged(selected.size()); + } + + @Override + public int getItemViewType(int position) { + return compact ? TYPE_COMPACT : TYPE_LARGE; + } + + @Override + public long getItemId(int position) { + return items.get(position).stem.hashCode(); + } + + @NonNull + @Override + public VH onCreateViewHolder(@NonNull ViewGroup parent, int viewType) { + int layout = viewType == TYPE_COMPACT + ? R.layout.item_recording_compact : R.layout.item_recording; + View v = LayoutInflater.from(parent.getContext()).inflate(layout, parent, false); + return new VH(v); + } + + @Override + public void onBindViewHolder(@NonNull VH h, int position) { + Recording r = items.get(position); + h.title.setText(r.stem); + + Bitmap bmp = Thumbs.CACHE.get(r.stem); + if (bmp != null) h.thumb.setImageBitmap(bmp); + else h.thumb.setImageDrawable(null); + + if (r.durationMs > 0) { + h.badge.setText(Format.clock(r.durationMs)); + h.badge.setVisibility(View.VISIBLE); + } else { + h.badge.setVisibility(View.GONE); + } + + // Selection visuals. + boolean isSelected = selected.contains(r.stem); + h.selectCheck.setVisibility(isSelected ? View.VISIBLE : View.GONE); + if (h.card != null) { + float density = h.itemView.getResources().getDisplayMetrics().density; + h.card.setStrokeWidth(Math.round((isSelected ? 2f : 1f) * density)); + h.card.setStrokeColor(ContextCompat.getColor(h.card.getContext(), + isSelected ? R.color.ruby : R.color.outline)); + } + + boolean ready = r.state == Recording.State.READY; + h.playOverlay.setVisibility(ready && !selectionMode ? View.VISIBLE : View.GONE); + + long shownSize = r.remoteSize > 0 ? r.remoteSize + : (r.localFile != null ? r.localFile.length() : 0); + String when = Format.date(r.mtimeEpoch); + + switch (r.state) { + case DOWNLOADING: + h.progress.setVisibility(View.VISIBLE); + h.progress.setIndeterminate(false); + h.progress.setProgress(r.progress); + h.meta.setText(h.ctx(R.string.state_downloading, r.progress)); + break; + case REMUXING: + h.progress.setVisibility(View.VISIBLE); + h.progress.setIndeterminate(true); + h.meta.setText(R.string.state_remuxing); + break; + case FAILED: + h.progress.setVisibility(View.GONE); + h.meta.setText(R.string.state_failed); + break; + case READY: + case ON_DRONE: + default: + h.progress.setVisibility(View.GONE); + h.meta.setText(join(Format.size(shownSize), when)); + break; + } + + // Action row (large layout only) is hidden while selecting. + if (h.actions != null) { + h.actions.setVisibility(selectionMode ? View.GONE : View.VISIBLE); + } + if (h.primary != null && !selectionMode) { + boolean busy = r.state == Recording.State.DOWNLOADING + || r.state == Recording.State.REMUXING; + h.primary.setVisibility(busy ? View.GONE : View.VISIBLE); + if (ready) { + h.primary.setText(R.string.action_play); + h.primary.setIconResource(R.drawable.ic_play_arrow); + } else { + h.primary.setText(R.string.action_download); + h.primary.setIconResource(R.drawable.ic_download); + } + h.primary.setOnClickListener(v -> { + if (r.state == Recording.State.READY) listener.onPlay(r); + else listener.onPrimary(r); + }); + h.share.setEnabled(ready); + h.delete.setEnabled(r.onDrone || r.isLocal()); + h.share.setOnClickListener(v -> listener.onShare(r)); + h.delete.setOnClickListener(v -> listener.onDelete(r)); + } + + View.OnClickListener tap = v -> { + if (selectionMode) { toggle(r); return; } + if (r.state == Recording.State.READY) listener.onPlay(r); + else if (r.state == Recording.State.ON_DRONE + || r.state == Recording.State.FAILED) listener.onPrimary(r); + }; + // Long-press must be wired on every clickable child too: a child with an + // OnClickListener is `clickable` and swallows the touch, so the card's + // own long-press detector never fires when you hold the thumbnail. + View.OnLongClickListener hold = v -> { + if (selectionMode) toggle(r); else enterSelection(r); + return true; + }; + h.playOverlay.setOnClickListener(tap); + h.thumb.setOnClickListener(tap); + h.itemView.setOnClickListener(tap); + h.playOverlay.setOnLongClickListener(hold); + h.thumb.setOnLongClickListener(hold); + h.itemView.setOnLongClickListener(hold); + } + + private static String join(String a, String b) { + if (b == null || b.isEmpty()) return a; + return a + " · " + b; + } + + @Override + public int getItemCount() { + return items.size(); + } + + static class VH extends RecyclerView.ViewHolder { + final ImageView thumb, playOverlay, selectCheck; + final TextView badge, title, meta; + final LinearProgressIndicator progress; + final MaterialCardView card; + final View actions; // null in compact layout + final MaterialButton primary, share, delete; // null in compact layout + + VH(@NonNull View v) { + super(v); + thumb = v.findViewById(R.id.thumb); + playOverlay = v.findViewById(R.id.play_overlay); + selectCheck = v.findViewById(R.id.select_check); + badge = v.findViewById(R.id.badge_duration); + title = v.findViewById(R.id.title); + meta = v.findViewById(R.id.meta); + progress = v.findViewById(R.id.progress); + card = (v instanceof MaterialCardView) ? (MaterialCardView) v : null; + actions = v.findViewById(R.id.actions); + primary = v.findViewById(R.id.btn_primary); + share = v.findViewById(R.id.btn_share); + delete = v.findViewById(R.id.btn_delete); + } + + String ctx(int resId, Object... args) { + return itemView.getContext().getString(resId, args); + } + } +} diff --git a/app/src/main/java/com/rubyfpv/viewer/recordings/RubyShell.java b/app/src/main/java/com/rubyfpv/viewer/recordings/RubyShell.java new file mode 100644 index 0000000..72076f3 --- /dev/null +++ b/app/src/main/java/com/rubyfpv/viewer/recordings/RubyShell.java @@ -0,0 +1,239 @@ +package com.rubyfpv.viewer.recordings; + +import android.util.Log; + +import com.jcraft.jsch.Channel; +import com.jcraft.jsch.ChannelExec; +import com.jcraft.jsch.JSch; +import com.jcraft.jsch.Session; + +import java.io.ByteArrayOutputStream; +import java.io.File; +import java.io.FileOutputStream; +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; +import java.util.ArrayList; +import java.util.List; +import java.util.Properties; + +/** + * SSH client for the drone, talking to its dropbear server. + * + *

The drone's dropbear has no SFTP / SCP subsystem (pscp / pscp -sftp + * both fail against the SigmaStar build), so this class never opens an SFTP channel. + * Instead it runs plain commands over {@code exec} channels — listing with + * {@code stat}, transferring by streaming {@code cat} — which is the same approach + * that works from the desktop via {@code plink + stdin}.

+ * + *

Not thread-safe: drive all calls from a single serial executor.

+ */ +public class RubyShell { + + private static final String TAG = "RubyShell"; + public static final String REMOTE_DIR = "/mnt/mmcblk0p1/ruby"; + + public static class Config { + public String host = "192.168.4.1"; + public int port = 22; + public String user = "root"; + public String pass = "12345"; + } + + public interface Progress { + void onBytes(long transferred, long total); + } + + private final Config cfg; + private Session session; + + public RubyShell(Config cfg) { + this.cfg = cfg; + } + + public void connect() throws Exception { + JSch jsch = new JSch(); + Session s = jsch.getSession(cfg.user, cfg.host, cfg.port); + s.setPassword(cfg.pass); + Properties p = new Properties(); + p.put("StrictHostKeyChecking", "no"); // point-to-point AP, no host DB + s.setConfig(p); + s.setConfig("PreferredAuthentications", "password,keyboard-interactive"); + s.setServerAliveInterval(15000); + s.connect(8000); + session = s; + Log.i(TAG, "Connected to " + cfg.user + "@" + cfg.host + ":" + cfg.port); + } + + public boolean isConnected() { + return session != null && session.isConnected(); + } + + public void close() { + if (session != null) { + session.disconnect(); + session = null; + } + } + + /** List {@code *.ts} (+ paired {@code *.osd}) in the recordings dir. */ + public List listRecordings() throws Exception { + // for-loop + stat keeps output trivially parseable: name|size|mtime + String cmd = "cd " + REMOTE_DIR + " 2>/dev/null && " + + "for f in *.ts *.osd; do [ -e \"$f\" ] && " + + "stat -c '%n|%s|%Y' \"$f\"; done"; + String out = exec(cmd); + + java.util.LinkedHashMap byStem = new java.util.LinkedHashMap<>(); + List osd = new ArrayList<>(); + + for (String line : out.split("\n")) { + line = line.trim(); + if (line.isEmpty()) continue; + String[] parts = line.split("\\|"); + if (parts.length < 3) continue; + String name = parts[0]; + long sz; + long mt; + try { + sz = Long.parseLong(parts[1].trim()); + mt = Long.parseLong(parts[2].trim()); + } catch (NumberFormatException e) { + continue; + } + if (name.endsWith(".ts")) { + String stem = name.substring(0, name.length() - 3); + Recording r = new Recording(stem); + r.tsName = name; + r.remoteSize = sz; + r.mtimeEpoch = mt; + r.onDrone = true; + byStem.put(stem, r); + } else if (name.endsWith(".osd")) { + osd.add(new String[]{name.substring(0, name.length() - 4), name}); + } + } + for (String[] o : osd) { + Recording r = byStem.get(o[0]); + if (r != null) r.osdName = o[1]; + } + return new ArrayList<>(byStem.values()); + } + + /** Stream a remote file to {@code dest} via {@code cat}. Returns bytes written. */ + public long download(String remoteName, long expectedSize, File dest, Progress cb) + throws Exception { + String cmd = "cat '" + REMOTE_DIR + "/" + remoteName + "'"; + ChannelExec ch = (ChannelExec) session.openChannel("exec"); + ch.setCommand(cmd); + ch.setInputStream(null); + InputStream in = ch.getInputStream(); + long total = 0; + try (OutputStream fout = new FileOutputStream(dest)) { + ch.connect(); + byte[] buf = new byte[64 * 1024]; + int n; + long lastCb = 0; + while ((n = in.read(buf)) != -1) { + fout.write(buf, 0, n); + total += n; + if (cb != null && (total - lastCb) > 256 * 1024) { + lastCb = total; + cb.onBytes(total, expectedSize); + } + } + fout.flush(); + } finally { + disconnect(ch); + } + if (cb != null) cb.onBytes(total, expectedSize); + int code = ch.getExitStatus(); + if (code != 0 && code != -1) { + throw new IOException("Remote cat exited " + code + " for " + remoteName); + } + return total; + } + + /** + * Pull only the first {@code bytes} of a remote file (for a thumbnail). The + * clip starts on a keyframe, so the first few MB contain a decodable frame. + */ + public long downloadPrefix(String remoteName, int bytes, File dest) throws Exception { + String cmd = "head -c " + bytes + " '" + REMOTE_DIR + "/" + remoteName + "'"; + ChannelExec ch = (ChannelExec) session.openChannel("exec"); + ch.setCommand(cmd); + ch.setInputStream(null); + InputStream in = ch.getInputStream(); + long total = 0; + try (OutputStream fout = new FileOutputStream(dest)) { + ch.connect(); + byte[] buf = new byte[64 * 1024]; + int n; + while ((n = in.read(buf)) != -1) { + fout.write(buf, 0, n); + total += n; + } + fout.flush(); + } finally { + disconnect(ch); + } + return total; + } + + /** + * Tell the drone to leave phone-transfer mode and reboot back into normal FPV. + * + *

On the drone, {@code /usr/bin/ap_mode.sh stop} simply reboots — that is the + * only reliable way out of the Realtek AP/monitor bounce (the script's own author made + * {@code stop} == {@code reboot}). The reboot tears down this very SSH link mid-command, + * so we background the command and do not wait for output or an exit status: a + * dropped connection right after issuing it is success, not failure. Callers should + * swallow any exception and treat it as "drone is rebooting".

+ */ + public void returnToFpv() throws Exception { + ChannelExec ch = (ChannelExec) session.openChannel("exec"); + // Background it so dropbear hands control back before the box goes down. + ch.setCommand("/usr/bin/ap_mode.sh stop >/dev/null 2>&1 &"); + ch.setInputStream(null); + try { + ch.connect(); + // Give dropbear a moment to receive and start the command before the link drops. + try { Thread.sleep(400); } catch (InterruptedException ignored) {} + } finally { + disconnect(ch); + } + } + + /** Delete one or more files from the recordings dir. */ + public void delete(String... names) throws Exception { + StringBuilder sb = new StringBuilder("rm -f"); + for (String n : names) { + if (n == null) continue; + sb.append(" '").append(REMOTE_DIR).append("/").append(n).append("'"); + } + exec(sb.toString()); + } + + // ── internals ─────────────────────────────────────────────────────── + + private String exec(String cmd) throws Exception { + ChannelExec ch = (ChannelExec) session.openChannel("exec"); + ch.setCommand(cmd); + ch.setInputStream(null); + ByteArrayOutputStream out = new ByteArrayOutputStream(); + InputStream in = ch.getInputStream(); + try { + ch.connect(); + byte[] buf = new byte[8192]; + int n; + while ((n = in.read(buf)) != -1) out.write(buf, 0, n); + } finally { + disconnect(ch); + } + return out.toString("UTF-8"); + } + + private static void disconnect(Channel ch) { + try { ch.disconnect(); } catch (Exception ignored) {} + } +} diff --git a/app/src/main/java/com/rubyfpv/viewer/recordings/Thumbs.java b/app/src/main/java/com/rubyfpv/viewer/recordings/Thumbs.java new file mode 100644 index 0000000..ec85d7a --- /dev/null +++ b/app/src/main/java/com/rubyfpv/viewer/recordings/Thumbs.java @@ -0,0 +1,146 @@ +package com.rubyfpv.viewer.recordings; + +import android.content.Context; +import android.graphics.Bitmap; +import android.graphics.BitmapFactory; +import android.net.Uri; +import android.util.LruCache; + +import androidx.media3.common.Effect; +import androidx.media3.common.MediaItem; +import androidx.media3.transformer.ExperimentalFrameExtractor; + +import com.google.common.util.concurrent.ListenableFuture; + +import java.io.File; +import java.io.FileOutputStream; +import java.util.Collections; +import java.util.concurrent.TimeUnit; + +/** + * First-frame thumbnails (DJI-style). + * + *

The clips are HEVC in an MPEG-TS container. Android's framework demuxer + * ({@link android.media.MediaExtractor} / {@link android.media.MediaMetadataRetriever}) + * cannot demux HEVC-in-TS — it opens the file but reports zero tracks — so + * every framework path (MMR, remux-to-MP4, MediaCodec) fails. media3's + * {@link ExperimentalFrameExtractor} runs the same ExoPlayer pipeline that plays + * the clips, whose {@code TsExtractor} does support HEVC, so it decodes the + * first frame directly from the {@code .ts} (or a downloaded {@code .ts} prefix).

+ */ +public final class Thumbs { + + private static final int MAX_W = 640; + private static final long FRAME_TIMEOUT_S = 20; + + public static final LruCache CACHE; + + static { + int kb = (int) (Runtime.getRuntime().maxMemory() / 1024); + CACHE = new LruCache(kb / 8) { + @Override + protected int sizeOf(String key, Bitmap value) { + return value.getByteCount() / 1024; + } + }; + } + + private Thumbs() {} + + public static class Meta { + public Bitmap bitmap; + public long durationMs = -1; // best-effort; raw TS carries no duration header + } + + public static Meta extract(Context ctx, File file, String stem) { + String id = stem != null ? stem : file.getName(); + DebugLog.add("extract " + id + " (" + file.length() + " B)"); + return finish(frame(ctx, Uri.fromFile(file)), stem, id); + } + + /** {@link #extract(Context, File, String)} but sourced from a {@code content://} Uri. */ + public static Meta extract(Context ctx, Uri uri, String stem) { + String id = stem != null ? stem : String.valueOf(uri); + DebugLog.add("extract " + id + " (uri)"); + return finish(frame(ctx, uri), stem, id); + } + + private static Meta finish(Bitmap bmp, String stem, String id) { + Meta m = new Meta(); + m.bitmap = bmp; + DebugLog.add(" media3-frame: " + (m.bitmap != null ? "OK" : "null")); + if (m.bitmap != null) { + m.bitmap = scale(m.bitmap); + if (stem != null) CACHE.put(stem, m.bitmap); + DebugLog.add(" => thumb OK for " + id); + } else { + DebugLog.add(" => thumb FAILED for " + id); + } + return m; + } + + /** Decode the first frame from a file. */ + public static Bitmap frame(Context ctx, File file) { + return frame(ctx, Uri.fromFile(file)); + } + + /** + * Decode the first frame via media3's ExoPlayer pipeline. Safe to call from a + * plain background thread (the extractor runs its own internal playback looper); + * blocks until the frame is ready or {@link #FRAME_TIMEOUT_S} elapses. + */ + public static Bitmap frame(Context ctx, Uri uri) { + ExperimentalFrameExtractor fe = new ExperimentalFrameExtractor( + ctx.getApplicationContext(), + new ExperimentalFrameExtractor.Configuration.Builder().build()); + try { + fe.setMediaItem(MediaItem.fromUri(uri), + Collections.emptyList()); + ListenableFuture future = fe.getFrame(0); + ExperimentalFrameExtractor.Frame f = future.get(FRAME_TIMEOUT_S, TimeUnit.SECONDS); + return f != null ? f.bitmap : null; + } catch (Exception e) { + return null; + } finally { + try { fe.release(); } catch (Exception ignored) {} + } + } + + // ── Persistence ───────────────────────────────────────────────────── + + public static void saveDisk(File thumbsDir, String stem, Bitmap bmp) { + if (bmp == null) return; + //noinspection ResultOfMethodCallIgnored + thumbsDir.mkdirs(); + File f = new File(thumbsDir, stem + ".jpg"); + try (FileOutputStream out = new FileOutputStream(f)) { + bmp.compress(Bitmap.CompressFormat.JPEG, 80, out); + } catch (Exception ignored) { + } + } + + public static void loadDiskInto(File thumbsDir) { + File[] files = thumbsDir.listFiles(); + if (files == null) return; + for (File f : files) { + String name = f.getName(); + if (!name.endsWith(".jpg")) continue; + String stem = name.substring(0, name.length() - 4); + if (CACHE.get(stem) != null) continue; + Bitmap b = BitmapFactory.decodeFile(f.getAbsolutePath()); + if (b != null) CACHE.put(stem, b); + } + } + + // ── Scaling ───────────────────────────────────────────────────────── + + private static Bitmap scale(Bitmap src) { + int w = src.getWidth(); + int h = src.getHeight(); + if (w <= MAX_W || w == 0) return src; + float ratio = (float) MAX_W / w; + Bitmap scaled = Bitmap.createScaledBitmap(src, MAX_W, Math.round(h * ratio), true); + if (scaled != src) src.recycle(); + return scaled; + } +} diff --git a/app/src/main/java/com/rubyfpv/viewer/recordings/WifiJoiner.java b/app/src/main/java/com/rubyfpv/viewer/recordings/WifiJoiner.java new file mode 100644 index 0000000..43fe83d --- /dev/null +++ b/app/src/main/java/com/rubyfpv/viewer/recordings/WifiJoiner.java @@ -0,0 +1,91 @@ +package com.rubyfpv.viewer.recordings; + +import android.content.Context; +import android.content.Intent; +import android.net.ConnectivityManager; +import android.net.Network; +import android.net.NetworkCapabilities; +import android.net.NetworkRequest; +import android.net.wifi.WifiNetworkSpecifier; +import android.os.Build; +import android.provider.Settings; +import android.util.Log; + +/** + * Joins the drone's Wi-Fi AP ("phone-transfer mode"). On API 29+ this uses a + * {@link WifiNetworkSpecifier} peer-to-peer request and binds the process to the + * resulting network so SSH traffic is routed to the drone (not cellular). On older + * devices, or when no SSID is configured yet, callers fall back to {@link #openWifiPanel}. + */ +public class WifiJoiner { + + private static final String TAG = "WifiJoiner"; + + public interface Callback { + void onBound(); + void onFailed(String reason); + } + + private final Context appCtx; + private final ConnectivityManager cm; + private ConnectivityManager.NetworkCallback callback; + + public WifiJoiner(Context ctx) { + this.appCtx = ctx.getApplicationContext(); + this.cm = (ConnectivityManager) appCtx.getSystemService(Context.CONNECTIVITY_SERVICE); + } + + public boolean isSupported() { + return Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q; + } + + /** Request and bind to the drone AP. Holds the binding until {@link #release()}. */ + public void join(String ssid, String psk, Callback cb) { + if (!isSupported()) { + cb.onFailed("Android version too old for in-app join"); + return; + } + release(); + WifiNetworkSpecifier.Builder b = new WifiNetworkSpecifier.Builder().setSsid(ssid); + if (psk != null && !psk.isEmpty()) b.setWpa2Passphrase(psk); + + NetworkRequest req = new NetworkRequest.Builder() + .addTransportType(NetworkCapabilities.TRANSPORT_WIFI) + .removeCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET) + .setNetworkSpecifier(b.build()) + .build(); + + callback = new ConnectivityManager.NetworkCallback() { + @Override + public void onAvailable(Network network) { + cm.bindProcessToNetwork(network); + Log.i(TAG, "Bound to drone AP " + ssid); + cb.onBound(); + } + + @Override + public void onUnavailable() { + Log.w(TAG, "AP " + ssid + " unavailable"); + cb.onFailed("Drone Wi-Fi not found"); + } + }; + cm.requestNetwork(req, callback); + } + + public void release() { + if (callback != null) { + try { cm.unregisterNetworkCallback(callback); } catch (Exception ignored) {} + callback = null; + } + try { cm.bindProcessToNetwork(null); } catch (Exception ignored) {} + } + + /** Open the system Wi-Fi picker so the user can join the AP manually. */ + public static void openWifiPanel(Context ctx) { + Intent intent = Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q + ? new Intent(Settings.Panel.ACTION_WIFI) + : new Intent(Settings.ACTION_WIFI_SETTINGS); + intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK); + ctx.startActivity(intent); + } +} diff --git a/app/src/main/res/drawable/badge_bg.xml b/app/src/main/res/drawable/badge_bg.xml new file mode 100644 index 0000000..7592bd8 --- /dev/null +++ b/app/src/main/res/drawable/badge_bg.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/app/src/main/res/drawable/ic_arrow_back.xml b/app/src/main/res/drawable/ic_arrow_back.xml new file mode 100644 index 0000000..f2e883a --- /dev/null +++ b/app/src/main/res/drawable/ic_arrow_back.xml @@ -0,0 +1,5 @@ + + + diff --git a/app/src/main/res/drawable/ic_check_circle.xml b/app/src/main/res/drawable/ic_check_circle.xml new file mode 100644 index 0000000..950a804 --- /dev/null +++ b/app/src/main/res/drawable/ic_check_circle.xml @@ -0,0 +1,6 @@ + + + diff --git a/app/src/main/res/drawable/ic_delete.xml b/app/src/main/res/drawable/ic_delete.xml new file mode 100644 index 0000000..2237161 --- /dev/null +++ b/app/src/main/res/drawable/ic_delete.xml @@ -0,0 +1,6 @@ + + + diff --git a/app/src/main/res/drawable/ic_download.xml b/app/src/main/res/drawable/ic_download.xml new file mode 100644 index 0000000..317fcb5 --- /dev/null +++ b/app/src/main/res/drawable/ic_download.xml @@ -0,0 +1,6 @@ + + + diff --git a/app/src/main/res/drawable/ic_grid.xml b/app/src/main/res/drawable/ic_grid.xml new file mode 100644 index 0000000..34b4e5d --- /dev/null +++ b/app/src/main/res/drawable/ic_grid.xml @@ -0,0 +1,6 @@ + + + diff --git a/app/src/main/res/drawable/ic_list.xml b/app/src/main/res/drawable/ic_list.xml new file mode 100644 index 0000000..efe3249 --- /dev/null +++ b/app/src/main/res/drawable/ic_list.xml @@ -0,0 +1,6 @@ + + + diff --git a/app/src/main/res/drawable/ic_live_tv.xml b/app/src/main/res/drawable/ic_live_tv.xml new file mode 100644 index 0000000..5d899a3 --- /dev/null +++ b/app/src/main/res/drawable/ic_live_tv.xml @@ -0,0 +1,6 @@ + + + diff --git a/app/src/main/res/drawable/ic_more_vert.xml b/app/src/main/res/drawable/ic_more_vert.xml new file mode 100644 index 0000000..cb6f8d9 --- /dev/null +++ b/app/src/main/res/drawable/ic_more_vert.xml @@ -0,0 +1,6 @@ + + + diff --git a/app/src/main/res/drawable/ic_play_arrow.xml b/app/src/main/res/drawable/ic_play_arrow.xml new file mode 100644 index 0000000..1d0090e --- /dev/null +++ b/app/src/main/res/drawable/ic_play_arrow.xml @@ -0,0 +1,6 @@ + + + diff --git a/app/src/main/res/drawable/ic_refresh.xml b/app/src/main/res/drawable/ic_refresh.xml new file mode 100644 index 0000000..d1810a5 --- /dev/null +++ b/app/src/main/res/drawable/ic_refresh.xml @@ -0,0 +1,6 @@ + + + diff --git a/app/src/main/res/drawable/ic_settings.xml b/app/src/main/res/drawable/ic_settings.xml new file mode 100644 index 0000000..78e9cb9 --- /dev/null +++ b/app/src/main/res/drawable/ic_settings.xml @@ -0,0 +1,6 @@ + + + diff --git a/app/src/main/res/drawable/ic_share.xml b/app/src/main/res/drawable/ic_share.xml new file mode 100644 index 0000000..cf08638 --- /dev/null +++ b/app/src/main/res/drawable/ic_share.xml @@ -0,0 +1,6 @@ + + + diff --git a/app/src/main/res/drawable/ic_videocam.xml b/app/src/main/res/drawable/ic_videocam.xml new file mode 100644 index 0000000..68e6e4a --- /dev/null +++ b/app/src/main/res/drawable/ic_videocam.xml @@ -0,0 +1,6 @@ + + + diff --git a/app/src/main/res/drawable/ic_wifi.xml b/app/src/main/res/drawable/ic_wifi.xml new file mode 100644 index 0000000..1c9fa8e --- /dev/null +++ b/app/src/main/res/drawable/ic_wifi.xml @@ -0,0 +1,6 @@ + + + diff --git a/app/src/main/res/drawable/play_scrim.xml b/app/src/main/res/drawable/play_scrim.xml new file mode 100644 index 0000000..8bcfa34 --- /dev/null +++ b/app/src/main/res/drawable/play_scrim.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/app/src/main/res/drawable/status_dot.xml b/app/src/main/res/drawable/status_dot.xml new file mode 100644 index 0000000..1308737 --- /dev/null +++ b/app/src/main/res/drawable/status_dot.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/app/src/main/res/drawable/thumb_placeholder.xml b/app/src/main/res/drawable/thumb_placeholder.xml new file mode 100644 index 0000000..43281e3 --- /dev/null +++ b/app/src/main/res/drawable/thumb_placeholder.xml @@ -0,0 +1,16 @@ + + + + + + + + + diff --git a/app/src/main/res/layout/activity_playback.xml b/app/src/main/res/layout/activity_playback.xml new file mode 100644 index 0000000..358d75d --- /dev/null +++ b/app/src/main/res/layout/activity_playback.xml @@ -0,0 +1,27 @@ + + + + + + + + diff --git a/app/src/main/res/layout/activity_recordings.xml b/app/src/main/res/layout/activity_recordings.xml new file mode 100644 index 0000000..1e15609 --- /dev/null +++ b/app/src/main/res/layout/activity_recordings.xml @@ -0,0 +1,131 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/layout/dialog_connection.xml b/app/src/main/res/layout/dialog_connection.xml new file mode 100644 index 0000000..e6e7a22 --- /dev/null +++ b/app/src/main/res/layout/dialog_connection.xml @@ -0,0 +1,99 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/layout/item_recording.xml b/app/src/main/res/layout/item_recording.xml new file mode 100644 index 0000000..ebc2f3c --- /dev/null +++ b/app/src/main/res/layout/item_recording.xml @@ -0,0 +1,172 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/layout/item_recording_compact.xml b/app/src/main/res/layout/item_recording_compact.xml new file mode 100644 index 0000000..1795ff1 --- /dev/null +++ b/app/src/main/res/layout/item_recording_compact.xml @@ -0,0 +1,117 @@ + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/menu/item_context.xml b/app/src/main/res/menu/item_context.xml new file mode 100644 index 0000000..cb198df --- /dev/null +++ b/app/src/main/res/menu/item_context.xml @@ -0,0 +1,7 @@ + + + + + + + diff --git a/app/src/main/res/menu/playback.xml b/app/src/main/res/menu/playback.xml new file mode 100644 index 0000000..79425da --- /dev/null +++ b/app/src/main/res/menu/playback.xml @@ -0,0 +1,9 @@ + + + + diff --git a/app/src/main/res/menu/recordings.xml b/app/src/main/res/menu/recordings.xml new file mode 100644 index 0000000..7f77664 --- /dev/null +++ b/app/src/main/res/menu/recordings.xml @@ -0,0 +1,47 @@ + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/values/colors.xml b/app/src/main/res/values/colors.xml new file mode 100644 index 0000000..a0d73a2 --- /dev/null +++ b/app/src/main/res/values/colors.xml @@ -0,0 +1,25 @@ + + + + #E0344A + #B5263A + #FFFFFF + + + #0F1113 + #1A1D21 + #23272C + #33383F + + + #F2F4F6 + #9AA3AD + #5A626B + + + #34C759 + #FFB020 + #6B7280 + + #CC000000 + diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index c581b75..14835b9 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -1,3 +1,72 @@ - RubyFPV Viewer + RubyFPV + + Recordings + Live view (USB) + Connection settings + Diagnostics + Refresh + Grid / list + + Connect + Disconnect + Join drone Wi-Fi + + Not connected + Connecting… + Connected to %1$s + Connection failed + Drone returning to FPV mode… + + Return to FPV mode + Return to FPV mode? + This reboots the drone out of phone-transfer mode and back into normal flight/FPV mode. The Wi-Fi hotspot will disconnect and the drone will be ready to fly again in about 45 seconds. + Reboot to FPV + Connect to the drone first. + + Connect to your drone + Put the drone in phone-transfer mode, join its Wi-Fi, then connect to browse onboard recordings. + No recordings found + Nothing on the drone SD card yet. Record some flights, then refresh. + + Download + Play + Share + Delete + On device + + On drone + Downloading… %1$d%% + Converting to MP4… + Ready to play + Failed + + Connection + Drone address + Username + Password + Save + Cancel + + Delete recording? + Delete \"%1$s\" from the drone SD card? The downloaded copy on this phone is kept. + Remove the downloaded copy of \"%1$s\" from this phone? The recording on the drone SD card is kept. + Delete \"%1$s\" from both this phone and the drone SD card? This cannot be undone. + Delete from phone + Delete from SD card + Delete from both + Remove local copy + Delete + + Delete selected + %1$d selected + Delete %1$d recordings? + Delete %1$d recordings from the %2$s? This cannot be undone. + phone + drone SD card + phone and drone SD card + + Share recording + No video player available + Could not share file diff --git a/app/src/main/res/values/styles.xml b/app/src/main/res/values/styles.xml index 9c8d271..8d758c7 100644 --- a/app/src/main/res/values/styles.xml +++ b/app/src/main/res/values/styles.xml @@ -1,7 +1,36 @@ - - + + + + + + diff --git a/app/src/main/res/xml/file_paths.xml b/app/src/main/res/xml/file_paths.xml new file mode 100644 index 0000000..20249a0 --- /dev/null +++ b/app/src/main/res/xml/file_paths.xml @@ -0,0 +1,7 @@ + + + + + + + diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 0185c20..5ed28b4 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -1,5 +1,19 @@ [versions] agp = "8.13.1" +material = "1.12.0" +constraintlayout = "2.1.4" +swiperefresh = "1.1.0" +jsch = "0.2.23" +media3 = "1.6.0" [plugins] android-application = { id = "com.android.application", version.ref = "agp" } + +[libraries] +material = { group = "com.google.android.material", name = "material", version.ref = "material" } +constraintlayout = { group = "androidx.constraintlayout", name = "constraintlayout", version.ref = "constraintlayout" } +swiperefreshlayout = { group = "androidx.swiperefreshlayout", name = "swiperefreshlayout", version.ref = "swiperefresh" } +jsch = { group = "com.github.mwiede", name = "jsch", version.ref = "jsch" } +media3-exoplayer = { group = "androidx.media3", name = "media3-exoplayer", version.ref = "media3" } +media3-ui = { group = "androidx.media3", name = "media3-ui", version.ref = "media3" } +media3-transformer = { group = "androidx.media3", name = "media3-transformer", version.ref = "media3" }