From 50637f5baa286a40ff0c5e37f3989e47998f7b52 Mon Sep 17 00:00:00 2001 From: def670 Date: Thu, 23 Apr 2026 01:25:58 -0400 Subject: [PATCH] v0.3.0: streaming upload rewrite with per-file teardown --- PROJECT_STATE.md | 22 + README.md | 13 + app/build.gradle | 4 +- app/src/main/AndroidManifest.xml | 2 +- .../outsidethebox/otbcloud/MainActivity.kt | 167 ++-- app/src/main/res/layout/activity_main.xml | 4 +- client | 0 patch3.sh | 811 ++++++++++++++++++ server | 0 9 files changed, 943 insertions(+), 80 deletions(-) create mode 100644 client create mode 100755 patch3.sh create mode 100644 server diff --git a/PROJECT_STATE.md b/PROJECT_STATE.md index f753850..b1d3f51 100644 --- a/PROJECT_STATE.md +++ b/PROJECT_STATE.md @@ -1,3 +1,25 @@ +## [v0.3.0] - Streaming upload rewrite + +### Upload behavior +- Uses HttpURLConnection chunked streaming mode +- Forces per-file response stream cleanup +- Forces per-file connection disconnect +- Keeps batching: + - videos: 2 + - images: 25 + +### Why +- v0.2.2 still hit OutOfMemoryError during long video runs +- crash remained inside uploadSingleFile() + +### Goal +- stop request-body memory buildup on large video uploads +- improve long-run stability for 100+ video sessions + +### Next +- if stable: raise batch sizes carefully +- if still unstable: move uploads into a foreground service + ## [v0.2.2] - Batched upload stabilization ### Upload behavior diff --git a/README.md b/README.md index 5cb4e95..6654abb 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,18 @@ # OTB Cloud Android Client +## Version +v0.3.0 + +## v0.3.0 notes +- Reworked upload path to use chunked streaming mode +- Forces per-file HTTP connection teardown +- Keeps batched upload handling +- Aims to eliminate memory buildup during long video runs + +## Notes +- Images: working +- Videos: streaming rewrite under live testing + Android uploader for OTB Cloud. ## Version diff --git a/app/build.gradle b/app/build.gradle index 05e02bf..2c3779b 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -11,8 +11,8 @@ android { applicationId "top.outsidethebox.otbcloud" minSdk 23 targetSdk 34 - versionCode 22 - versionName "0.2.2" + versionCode 30 + versionName "0.3.0" testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" } diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 46250aa..0b14d8a 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -17,7 +17,7 @@ diff --git a/app/src/main/java/top/outsidethebox/otbcloud/MainActivity.kt b/app/src/main/java/top/outsidethebox/otbcloud/MainActivity.kt index 09370f3..43e1b68 100644 --- a/app/src/main/java/top/outsidethebox/otbcloud/MainActivity.kt +++ b/app/src/main/java/top/outsidethebox/otbcloud/MainActivity.kt @@ -48,10 +48,15 @@ class MainActivity : AppCompatActivity() { private const val EXISTS_URL = "https://otb-cloud.outsidethebox.top/api/android/file-exists" private const val REQ_READ_STORAGE = 2001 + + // v0.3.0 upload rewrite defaults private const val VIDEO_BATCH_SIZE = 2 private const val IMAGE_BATCH_SIZE = 25 + private const val PAUSE_BETWEEN_FILES_MS = 350L private const val PAUSE_BETWEEN_BATCHES_MS = 1500L - private const val PAUSE_BETWEEN_FILES_MS = 300L + private const val STREAM_CHUNK_SIZE = 8192 + private const val CONNECT_TIMEOUT_MS = 30000 + private const val READ_TIMEOUT_MS = 60000 } enum class ScanMode { @@ -189,43 +194,50 @@ class MainActivity : AppCompatActivity() { connectTimeout = 15000 readTimeout = 20000 doOutput = true + useCaches = false setRequestProperty("Content-Type", "application/json") setRequestProperty("Accept", "application/json") + setRequestProperty("Connection", "close") } - OutputStreamWriter(conn.outputStream, Charsets.UTF_8).use { writer -> - writer.write(payload.toString()) - writer.flush() - } + try { + OutputStreamWriter(conn.outputStream, Charsets.UTF_8).use { writer -> + writer.write(payload.toString()) + writer.flush() + } - val responseCode = conn.responseCode - val stream = if (responseCode in 200..299) conn.inputStream else conn.errorStream - val responseText = stream?.bufferedReader()?.use(BufferedReader::readText).orEmpty() - val json = if (responseText.isNotBlank()) JSONObject(responseText) else JSONObject() + val responseCode = conn.responseCode + val responseText = (if (responseCode in 200..299) conn.inputStream else conn.errorStream) + ?.bufferedReader() + ?.use(BufferedReader::readText) + .orEmpty() - runOnUiThread { - if (responseCode in 200..299 && json.optBoolean("ok", false)) { - val prefs = getPrefs() - prefs.edit() - .putBoolean(KEY_ACTIVATED, true) - .putString(KEY_TOKEN, token) - .putInt(KEY_DEVICE_ID, json.optInt("device_id", 0)) - .putString(KEY_DEVICE_NAME, json.optString("device_name", "")) - .putString(KEY_DEVICE_LABEL, json.optString("device_label", "")) - .putString(KEY_RELATIVE_PATH, json.optString("relative_path", "")) - .apply() - - Toast.makeText(this, "Activation successful", Toast.LENGTH_LONG).show() - showMediaSection() - } else { - val errorText = json.optString("error", "activation_failed") - binding.activateButton.isEnabled = true - binding.statusText.text = "Activation failed: $errorText" - Toast.makeText(this, "Activation failed: $errorText", Toast.LENGTH_LONG).show() + val json = if (responseText.isNotBlank()) JSONObject(responseText) else JSONObject() + + runOnUiThread { + if (responseCode in 200..299 && json.optBoolean("ok", false)) { + val prefs = getPrefs() + prefs.edit() + .putBoolean(KEY_ACTIVATED, true) + .putString(KEY_TOKEN, token) + .putInt(KEY_DEVICE_ID, json.optInt("device_id", 0)) + .putString(KEY_DEVICE_NAME, json.optString("device_name", "")) + .putString(KEY_DEVICE_LABEL, json.optString("device_label", "")) + .putString(KEY_RELATIVE_PATH, json.optString("relative_path", "")) + .apply() + + Toast.makeText(this, "Activation successful", Toast.LENGTH_LONG).show() + showMediaSection() + } else { + val errorText = json.optString("error", "activation_failed") + binding.activateButton.isEnabled = true + binding.statusText.text = "Activation failed: $errorText" + Toast.makeText(this, "Activation failed: $errorText", Toast.LENGTH_LONG).show() + } } + } finally { + conn.disconnect() } - - conn.disconnect() } catch (e: Exception) { runOnUiThread { binding.activateButton.isEnabled = true @@ -404,13 +416,7 @@ class MainActivity : AppCompatActivity() { } } - private fun batchSizeForSelection(selected: List): Int { - val containsVideo = selected.any { it.mimeType.startsWith("video/") } - return if (containsVideo) VIDEO_BATCH_SIZE else IMAGE_BATCH_SIZE - } - - -private fun uploadSelectedMedia(selected: List) { + private fun uploadSelectedMedia(selected: List) { val prefs = getPrefs() val deviceUuid = prefs.getString(KEY_DEVICE_UUID, null) @@ -466,8 +472,6 @@ private fun uploadSelectedMedia(selected: List) { Thread.sleep(PAUSE_BETWEEN_FILES_MS) } catch (_: Exception) { } - - System.gc() } if (batchIndex < batches.lastIndex) { @@ -479,7 +483,6 @@ private fun uploadSelectedMedia(selected: List) { Thread.sleep(PAUSE_BETWEEN_BATCHES_MS) } catch (_: Exception) { } - System.gc() } } @@ -500,6 +503,7 @@ private fun uploadSelectedMedia(selected: List) { } private fun serverFileExists(deviceUuid: String, item: MediaItem): Boolean { + var conn: HttpURLConnection? = null return try { val url = URL( EXISTS_URL + @@ -508,82 +512,95 @@ private fun uploadSelectedMedia(selected: List) { "&size_bytes=" + item.sizeBytes ) - val conn = (url.openConnection() as HttpURLConnection).apply { + conn = (url.openConnection() as HttpURLConnection).apply { requestMethod = "GET" connectTimeout = 15000 readTimeout = 20000 + useCaches = false setRequestProperty("Accept", "application/json") + setRequestProperty("Connection", "close") } val responseCode = conn.responseCode - val stream = if (responseCode in 200..299) conn.inputStream else conn.errorStream - val responseText = stream?.bufferedReader()?.use(BufferedReader::readText).orEmpty() - conn.disconnect() + val responseText = (if (responseCode in 200..299) conn.inputStream else conn.errorStream) + ?.bufferedReader() + ?.use(BufferedReader::readText) + .orEmpty() if (responseCode !in 200..299) return false val json = if (responseText.isNotBlank()) JSONObject(responseText) else JSONObject() json.optBoolean("exists", false) } catch (_: Exception) { false + } finally { + conn?.disconnect() } } private fun uploadSingleFile(deviceUuid: String, item: MediaItem): Boolean { + var conn: HttpURLConnection? = null return try { val boundary = "----OTBCloudBoundary${System.currentTimeMillis()}" val file = File(item.path) if (!file.exists()) return false val url = URL(UPLOAD_URL) - val conn = (url.openConnection() as HttpURLConnection).apply { + conn = (url.openConnection() as HttpURLConnection).apply { requestMethod = "POST" - connectTimeout = 30000 - readTimeout = 60000 + connectTimeout = CONNECT_TIMEOUT_MS + readTimeout = READ_TIMEOUT_MS doOutput = true + doInput = true + useCaches = false + setChunkedStreamingMode(STREAM_CHUNK_SIZE) setRequestProperty("Content-Type", "multipart/form-data; boundary=$boundary") setRequestProperty("Accept", "application/json") + setRequestProperty("Connection", "close") } - val output = DataOutputStream(conn.outputStream) - - fun writeFormField(name: String, value: String) { - output.writeBytes("--$boundary\r\n") - output.writeBytes("Content-Disposition: form-data; name=\"$name\"\r\n\r\n") - output.writeBytes(value) - output.writeBytes("\r\n") - } - - writeFormField("device_uuid", deviceUuid) + DataOutputStream(conn.outputStream).use { output -> + fun writeFormField(name: String, value: String) { + output.writeBytes("--$boundary\r\n") + output.writeBytes("Content-Disposition: form-data; name=\"$name\"\r\n\r\n") + output.writeBytes(value) + output.writeBytes("\r\n") + } - output.writeBytes("--$boundary\r\n") - output.writeBytes("Content-Disposition: form-data; name=\"files\"; filename=\"${file.name}\"\r\n") - output.writeBytes("Content-Type: ${item.mimeType}\r\n\r\n") + writeFormField("device_uuid", deviceUuid) - FileInputStream(file).use { input -> - val buffer = ByteArray(8192) - while (true) { - val read = input.read(buffer) - if (read == -1) break - output.write(buffer, 0, read) + output.writeBytes("--$boundary\r\n") + output.writeBytes("Content-Disposition: form-data; name=\"files\"; filename=\"${file.name}\"\r\n") + output.writeBytes("Content-Type: ${item.mimeType}\r\n\r\n") + + FileInputStream(file).use { input -> + val buffer = ByteArray(STREAM_CHUNK_SIZE) + while (true) { + val read = input.read(buffer) + if (read == -1) break + output.write(buffer, 0, read) + } } - } - output.writeBytes("\r\n") - output.writeBytes("--$boundary--\r\n") - output.flush() - output.close() + output.writeBytes("\r\n") + output.writeBytes("--$boundary--\r\n") + output.flush() + } val responseCode = conn.responseCode - val stream = if (responseCode in 200..299) conn.inputStream else conn.errorStream - val responseText = stream?.bufferedReader()?.use(BufferedReader::readText).orEmpty() - - conn.disconnect() + val responseText = (if (responseCode in 200..299) conn.inputStream else conn.errorStream) + ?.bufferedReader() + ?.use(BufferedReader::readText) + .orEmpty() if (responseCode !in 200..299) return false val json = if (responseText.isNotBlank()) JSONObject(responseText) else JSONObject() json.optBoolean("ok", false) + } catch (_: OutOfMemoryError) { + false } catch (_: Exception) { false + } finally { + conn?.disconnect() } } diff --git a/app/src/main/res/layout/activity_main.xml b/app/src/main/res/layout/activity_main.xml index b59fa87..ae70eab 100644 --- a/app/src/main/res/layout/activity_main.xml +++ b/app/src/main/res/layout/activity_main.xml @@ -15,7 +15,7 @@ android:id="@+id/titleText" android:layout_width="wrap_content" android:layout_height="wrap_content" - android:text="OTB Cloud v0.2.2" + android:text="OTB Cloud v0.3.0" android:textColor="#FFFFFF" android:textSize="28sp" android:textStyle="bold" @@ -25,7 +25,7 @@ android:id="@+id/subtitleText" android:layout_width="wrap_content" android:layout_height="wrap_content" - android:text="Android Backup Client v0.2.2" + android:text="Android Backup Client v0.3.0" android:textColor="#B8C7E0" android:textSize="16sp" android:layout_marginTop="8dp" diff --git a/client b/client new file mode 100644 index 0000000..e69de29 diff --git a/patch3.sh b/patch3.sh new file mode 100755 index 0000000..13f8fb7 --- /dev/null +++ b/patch3.sh @@ -0,0 +1,811 @@ +cd /home/def/outsidethebox/otb-cloud-android || exit 1 +set -e + +BACKUPDIR="/home/def/backuphere/otb-cloud-android" +mkdir -p "$BACKUPDIR" +mkdir -p /home/def/outsidethebox/apk-drop + +echo "===== BACKUP =====" +cp /home/def/outsidethebox/otb-cloud-android/app/src/main/java/top/outsidethebox/otbcloud/MainActivity.kt \ + "$BACKUPDIR/MainActivity.kt.v0.3.0.$(date +%Y%m%d-%H%M%S).bak" +cp /home/def/outsidethebox/otb-cloud-android/app/build.gradle \ + "$BACKUPDIR/app.build.gradle.v0.3.0.$(date +%Y%m%d-%H%M%S).bak" +cp /home/def/outsidethebox/otb-cloud-android/app/src/main/AndroidManifest.xml \ + "$BACKUPDIR/AndroidManifest.xml.v0.3.0.$(date +%Y%m%d-%H%M%S).bak" +cp /home/def/outsidethebox/otb-cloud-android/app/src/main/res/layout/activity_main.xml \ + "$BACKUPDIR/activity_main.xml.v0.3.0.$(date +%Y%m%d-%H%M%S).bak" +[ -f /home/def/outsidethebox/otb-cloud-android/README.md ] && \ + cp /home/def/outsidethebox/otb-cloud-android/README.md \ + "$BACKUPDIR/README.md.v0.3.0.$(date +%Y%m%d-%H%M%S).bak" +[ -f /home/def/outsidethebox/otb-cloud-android/PROJECT_STATE.md ] && \ + cp /home/def/outsidethebox/otb-cloud-android/PROJECT_STATE.md \ + "$BACKUPDIR/PROJECT_STATE.md.v0.3.0.$(date +%Y%m%d-%H%M%S).bak" + +echo "===== WRITE MainActivity.kt =====" +cat > /home/def/outsidethebox/otb-cloud-android/app/src/main/java/top/outsidethebox/otbcloud/MainActivity.kt <<'EOF' +package top.outsidethebox.otbcloud + +import android.Manifest +import android.content.Context +import android.content.pm.PackageManager +import android.os.Build +import android.os.Bundle +import android.provider.MediaStore +import android.view.View +import android.widget.ArrayAdapter +import android.widget.ListView +import android.widget.Toast +import androidx.appcompat.app.AppCompatActivity +import androidx.core.app.ActivityCompat +import androidx.core.content.ContextCompat +import org.json.JSONObject +import top.outsidethebox.otbcloud.databinding.ActivityMainBinding +import java.io.BufferedReader +import java.io.DataOutputStream +import java.io.File +import java.io.FileInputStream +import java.io.OutputStreamWriter +import java.net.HttpURLConnection +import java.net.URLEncoder +import java.net.URL +import java.util.UUID +import java.util.concurrent.Executors + +class MainActivity : AppCompatActivity() { + + private lateinit var binding: ActivityMainBinding + private val executor = Executors.newSingleThreadExecutor() + private val mediaItems = mutableListOf() + private lateinit var mediaAdapter: ArrayAdapter + + companion object { + private const val PREFS_NAME = "otb_cloud_prefs" + private const val KEY_DEVICE_UUID = "device_uuid" + private const val KEY_ACTIVATED = "activated" + private const val KEY_DEVICE_ID = "device_id" + private const val KEY_DEVICE_NAME = "device_name" + private const val KEY_DEVICE_LABEL = "device_label" + private const val KEY_RELATIVE_PATH = "relative_path" + private const val KEY_TOKEN = "token" + + private const val ACTIVATE_URL = "https://otb-cloud.outsidethebox.top/api/android/activate" + private const val UPLOAD_URL = "https://otb-cloud.outsidethebox.top/api/android/upload" + private const val EXISTS_URL = "https://otb-cloud.outsidethebox.top/api/android/file-exists" + + private const val REQ_READ_STORAGE = 2001 + + // v0.3.0 upload rewrite defaults + private const val VIDEO_BATCH_SIZE = 2 + private const val IMAGE_BATCH_SIZE = 25 + private const val PAUSE_BETWEEN_FILES_MS = 350L + private const val PAUSE_BETWEEN_BATCHES_MS = 1500L + private const val STREAM_CHUNK_SIZE = 8192 + private const val CONNECT_TIMEOUT_MS = 30000 + private const val READ_TIMEOUT_MS = 60000 + } + + enum class ScanMode { + IMAGES, + VIDEOS, + BOTH + } + + data class MediaItem( + val path: String, + val displayName: String, + val sizeBytes: Long, + val mimeType: String + ) + + private var currentScanMode = ScanMode.BOTH + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + binding = ActivityMainBinding.inflate(layoutInflater) + setContentView(binding.root) + + mediaAdapter = ArrayAdapter( + this, + android.R.layout.simple_list_item_multiple_choice, + mutableListOf() + ) + binding.mediaListView.choiceMode = ListView.CHOICE_MODE_MULTIPLE + binding.mediaListView.adapter = mediaAdapter + + val deviceUuid = getOrCreateDeviceUuid() + + binding.mediaListView.setOnItemClickListener { _, _, _, _ -> + updateSelectionInfo() + } + + updateScanButtonLabel() + + binding.scanButton.setOnClickListener { + cycleScanMode() + } + + binding.runScanButton.setOnClickListener { + ensureStoragePermissionAndScanMedia() + } + + binding.selectAllButton.setOnClickListener { + for (i in mediaItems.indices) { + binding.mediaListView.setItemChecked(i, true) + } + updateSelectionInfo() + } + + binding.clearAllButton.setOnClickListener { + for (i in mediaItems.indices) { + binding.mediaListView.setItemChecked(i, false) + } + updateSelectionInfo() + } + + binding.uploadSelectedButton.setOnClickListener { + val selected = getSelectedItems() + if (selected.isEmpty()) { + Toast.makeText(this, "No media selected", Toast.LENGTH_SHORT).show() + return@setOnClickListener + } + uploadSelectedMedia(selected) + } + + binding.resetActivationButton.setOnClickListener { + resetActivation() + showActivationSection(deviceUuid) + Toast.makeText(this, "Activation cleared. Enter a new token.", Toast.LENGTH_LONG).show() + } + + if (getPrefs().getBoolean(KEY_ACTIVATED, false)) { + showMediaSection() + } else { + showActivationSection(deviceUuid) + } + } + + private fun getPrefs() = getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE) + + private fun resetActivation() { + val prefs = getPrefs() + prefs.edit() + .remove(KEY_ACTIVATED) + .remove(KEY_DEVICE_ID) + .remove(KEY_DEVICE_NAME) + .remove(KEY_DEVICE_LABEL) + .remove(KEY_RELATIVE_PATH) + .remove(KEY_TOKEN) + .apply() + + mediaItems.clear() + mediaAdapter.clear() + mediaAdapter.notifyDataSetChanged() + binding.statusText.text = "Activation cleared" + updateSelectionInfo() + } + + private fun showActivationSection(deviceUuid: String) { + binding.activationSection.visibility = View.VISIBLE + binding.mediaSection.visibility = View.GONE + binding.tokenInput.isEnabled = true + binding.activateButton.isEnabled = true + binding.activateButton.text = "Activate" + binding.tokenInput.setText("") + binding.statusText.text = "Device UUID: $deviceUuid" + + binding.activateButton.setOnClickListener { + val token = binding.tokenInput.text?.toString()?.trim().orEmpty() + if (token.isEmpty()) { + binding.statusText.text = "Token required" + return@setOnClickListener + } + + binding.activateButton.isEnabled = false + binding.statusText.text = "Activating device..." + + val phoneLabel = buildPhoneLabel() + + executor.execute { + try { + val payload = JSONObject().apply { + put("token", token) + put("device_uuid", deviceUuid) + put("phone_label", phoneLabel) + } + + val url = URL(ACTIVATE_URL) + val conn = (url.openConnection() as HttpURLConnection).apply { + requestMethod = "POST" + connectTimeout = 15000 + readTimeout = 20000 + doOutput = true + useCaches = false + setRequestProperty("Content-Type", "application/json") + setRequestProperty("Accept", "application/json") + setRequestProperty("Connection", "close") + } + + try { + OutputStreamWriter(conn.outputStream, Charsets.UTF_8).use { writer -> + writer.write(payload.toString()) + writer.flush() + } + + val responseCode = conn.responseCode + val responseText = (if (responseCode in 200..299) conn.inputStream else conn.errorStream) + ?.bufferedReader() + ?.use(BufferedReader::readText) + .orEmpty() + + val json = if (responseText.isNotBlank()) JSONObject(responseText) else JSONObject() + + runOnUiThread { + if (responseCode in 200..299 && json.optBoolean("ok", false)) { + val prefs = getPrefs() + prefs.edit() + .putBoolean(KEY_ACTIVATED, true) + .putString(KEY_TOKEN, token) + .putInt(KEY_DEVICE_ID, json.optInt("device_id", 0)) + .putString(KEY_DEVICE_NAME, json.optString("device_name", "")) + .putString(KEY_DEVICE_LABEL, json.optString("device_label", "")) + .putString(KEY_RELATIVE_PATH, json.optString("relative_path", "")) + .apply() + + Toast.makeText(this, "Activation successful", Toast.LENGTH_LONG).show() + showMediaSection() + } else { + val errorText = json.optString("error", "activation_failed") + binding.activateButton.isEnabled = true + binding.statusText.text = "Activation failed: $errorText" + Toast.makeText(this, "Activation failed: $errorText", Toast.LENGTH_LONG).show() + } + } + } finally { + conn.disconnect() + } + } catch (e: Exception) { + runOnUiThread { + binding.activateButton.isEnabled = true + binding.statusText.text = "Activation error: ${e.message ?: "unknown error"}" + Toast.makeText(this, "Activation error", Toast.LENGTH_LONG).show() + } + } + } + } + } + + private fun showMediaSection() { + val prefs = getPrefs() + val deviceName = prefs.getString(KEY_DEVICE_NAME, "unknown-device") ?: "unknown-device" + val relativePath = prefs.getString(KEY_RELATIVE_PATH, "") ?: "" + + binding.activationSection.visibility = View.GONE + binding.mediaSection.visibility = View.VISIBLE + binding.deviceInfoText.text = "Activated to $deviceName\n$relativePath" + binding.statusText.text = "Ready to scan media" + updateScanButtonLabel() + updateSelectionInfo() + } + + private fun cycleScanMode() { + currentScanMode = when (currentScanMode) { + ScanMode.IMAGES -> ScanMode.VIDEOS + ScanMode.VIDEOS -> ScanMode.BOTH + ScanMode.BOTH -> ScanMode.IMAGES + } + updateScanButtonLabel() + } + + private fun updateScanButtonLabel() { + binding.scanButton.text = when (currentScanMode) { + ScanMode.IMAGES -> "Mode: Images" + ScanMode.VIDEOS -> "Mode: Videos" + ScanMode.BOTH -> "Mode: Images + Videos" + } + } + + private fun ensureStoragePermissionAndScanMedia() { + val permissions = if (Build.VERSION.SDK_INT >= 33) { + when (currentScanMode) { + ScanMode.IMAGES -> arrayOf(Manifest.permission.READ_MEDIA_IMAGES) + ScanMode.VIDEOS -> arrayOf(Manifest.permission.READ_MEDIA_VIDEO) + ScanMode.BOTH -> arrayOf( + Manifest.permission.READ_MEDIA_IMAGES, + Manifest.permission.READ_MEDIA_VIDEO + ) + } + } else { + arrayOf(Manifest.permission.READ_EXTERNAL_STORAGE) + } + + val granted = permissions.all { permission -> + ContextCompat.checkSelfPermission(this, permission) == PackageManager.PERMISSION_GRANTED + } + + if (granted) { + scanCameraMedia() + } else { + ActivityCompat.requestPermissions(this, permissions, REQ_READ_STORAGE) + } + } + + private fun scanCameraMedia() { + executor.execute { + val results = mutableListOf() + + fun scanImages() { + val projection = arrayOf( + MediaStore.Images.Media.DATA, + MediaStore.Images.Media.DISPLAY_NAME, + MediaStore.Images.Media.SIZE, + MediaStore.Images.Media.BUCKET_DISPLAY_NAME + ) + + val cursor = contentResolver.query( + MediaStore.Images.Media.EXTERNAL_CONTENT_URI, + projection, + "${MediaStore.Images.Media.BUCKET_DISPLAY_NAME} = ?", + arrayOf("Camera"), + MediaStore.Images.Media.DATE_ADDED + " DESC" + ) + + cursor?.use { + val dataIndex = it.getColumnIndexOrThrow(MediaStore.Images.Media.DATA) + val nameIndex = it.getColumnIndexOrThrow(MediaStore.Images.Media.DISPLAY_NAME) + val sizeIndex = it.getColumnIndexOrThrow(MediaStore.Images.Media.SIZE) + + while (it.moveToNext()) { + val path = it.getString(dataIndex) ?: continue + val name = it.getString(nameIndex) ?: "unknown" + val size = it.getLong(sizeIndex) + results.add( + MediaItem( + path = path, + displayName = name, + sizeBytes = size, + mimeType = "image/*" + ) + ) + } + } + } + + fun scanVideos() { + val projection = arrayOf( + MediaStore.Video.Media.DATA, + MediaStore.Video.Media.DISPLAY_NAME, + MediaStore.Video.Media.SIZE, + MediaStore.Video.Media.BUCKET_DISPLAY_NAME + ) + + val cursor = contentResolver.query( + MediaStore.Video.Media.EXTERNAL_CONTENT_URI, + projection, + "${MediaStore.Video.Media.BUCKET_DISPLAY_NAME} = ?", + arrayOf("Camera"), + MediaStore.Video.Media.DATE_ADDED + " DESC" + ) + + cursor?.use { + val dataIndex = it.getColumnIndexOrThrow(MediaStore.Video.Media.DATA) + val nameIndex = it.getColumnIndexOrThrow(MediaStore.Video.Media.DISPLAY_NAME) + val sizeIndex = it.getColumnIndexOrThrow(MediaStore.Video.Media.SIZE) + + while (it.moveToNext()) { + val path = it.getString(dataIndex) ?: continue + val name = it.getString(nameIndex) ?: "unknown" + val size = it.getLong(sizeIndex) + results.add( + MediaItem( + path = path, + displayName = name, + sizeBytes = size, + mimeType = "video/mp4" + ) + ) + } + } + } + + when (currentScanMode) { + ScanMode.IMAGES -> scanImages() + ScanMode.VIDEOS -> scanVideos() + ScanMode.BOTH -> { + scanImages() + scanVideos() + } + } + + runOnUiThread { + mediaItems.clear() + mediaItems.addAll(results) + + mediaAdapter.clear() + mediaAdapter.addAll( + results.map { + val icon = if (it.mimeType.startsWith("video/")) "🎥" else "📷" + "$icon ${it.displayName} (${formatBytes(it.sizeBytes)})" + } + ) + mediaAdapter.notifyDataSetChanged() + + for (i in mediaItems.indices) { + binding.mediaListView.setItemChecked(i, false) + } + + binding.statusText.text = "Scan complete: ${results.size} media file(s) found" + updateSelectionInfo() + + Toast.makeText(this, "Found ${results.size} media file(s)", Toast.LENGTH_LONG).show() + } + } + } + + private fun uploadSelectedMedia(selected: List) { + val prefs = getPrefs() + val deviceUuid = prefs.getString(KEY_DEVICE_UUID, null) + + if (deviceUuid.isNullOrBlank()) { + binding.statusText.text = "Missing local device UUID" + Toast.makeText(this, "Missing device UUID", Toast.LENGTH_LONG).show() + return + } + + binding.uploadSelectedButton.isEnabled = false + binding.scanButton.isEnabled = false + binding.runScanButton.isEnabled = false + binding.selectAllButton.isEnabled = false + binding.clearAllButton.isEnabled = false + + executor.execute { + var uploaded = 0 + var skipped = 0 + var failed = 0 + + val containsVideo = selected.any { it.mimeType.startsWith("video/") } + val batchSize = if (containsVideo) VIDEO_BATCH_SIZE else IMAGE_BATCH_SIZE + val batches = selected.chunked(batchSize) + + for ((batchIndex, batch) in batches.withIndex()) { + runOnUiThread { + binding.statusText.text = + "Starting batch ${batchIndex + 1} of ${batches.size} (${batch.size} file(s))" + } + + for ((itemIndex, item) in batch.withIndex()) { + runOnUiThread { + binding.statusText.text = + "Batch ${batchIndex + 1}/${batches.size} - File ${itemIndex + 1}/${batch.size}: ${item.displayName}" + } + + if (serverFileExists(deviceUuid, item)) { + skipped += 1 + runOnUiThread { + binding.statusText.text = + "Batch ${batchIndex + 1}/${batches.size} - Skipping existing: ${item.displayName}" + } + } else { + val ok = uploadSingleFile(deviceUuid, item) + if (ok) { + uploaded += 1 + } else { + failed += 1 + } + } + + try { + Thread.sleep(PAUSE_BETWEEN_FILES_MS) + } catch (_: Exception) { + } + } + + if (batchIndex < batches.lastIndex) { + runOnUiThread { + binding.statusText.text = + "Completed batch ${batchIndex + 1}/${batches.size}. Pausing before next batch..." + } + try { + Thread.sleep(PAUSE_BETWEEN_BATCHES_MS) + } catch (_: Exception) { + } + } + } + + runOnUiThread { + binding.uploadSelectedButton.isEnabled = true + binding.scanButton.isEnabled = true + binding.runScanButton.isEnabled = true + binding.selectAllButton.isEnabled = true + binding.clearAllButton.isEnabled = true + binding.statusText.text = "Upload complete: $uploaded uploaded, $skipped skipped, $failed failed" + Toast.makeText( + this, + "Upload complete: $uploaded uploaded, $skipped skipped, $failed failed", + Toast.LENGTH_LONG + ).show() + } + } + } + + private fun serverFileExists(deviceUuid: String, item: MediaItem): Boolean { + var conn: HttpURLConnection? = null + return try { + val url = URL( + EXISTS_URL + + "?device_uuid=" + URLEncoder.encode(deviceUuid, "UTF-8") + + "&original_filename=" + URLEncoder.encode(item.displayName, "UTF-8") + + "&size_bytes=" + item.sizeBytes + ) + + conn = (url.openConnection() as HttpURLConnection).apply { + requestMethod = "GET" + connectTimeout = 15000 + readTimeout = 20000 + useCaches = false + setRequestProperty("Accept", "application/json") + setRequestProperty("Connection", "close") + } + + val responseCode = conn.responseCode + val responseText = (if (responseCode in 200..299) conn.inputStream else conn.errorStream) + ?.bufferedReader() + ?.use(BufferedReader::readText) + .orEmpty() + + if (responseCode !in 200..299) return false + val json = if (responseText.isNotBlank()) JSONObject(responseText) else JSONObject() + json.optBoolean("exists", false) + } catch (_: Exception) { + false + } finally { + conn?.disconnect() + } + } + + private fun uploadSingleFile(deviceUuid: String, item: MediaItem): Boolean { + var conn: HttpURLConnection? = null + return try { + val boundary = "----OTBCloudBoundary${System.currentTimeMillis()}" + val file = File(item.path) + if (!file.exists()) return false + + val url = URL(UPLOAD_URL) + conn = (url.openConnection() as HttpURLConnection).apply { + requestMethod = "POST" + connectTimeout = CONNECT_TIMEOUT_MS + readTimeout = READ_TIMEOUT_MS + doOutput = true + doInput = true + useCaches = false + setChunkedStreamingMode(STREAM_CHUNK_SIZE) + setRequestProperty("Content-Type", "multipart/form-data; boundary=$boundary") + setRequestProperty("Accept", "application/json") + setRequestProperty("Connection", "close") + } + + DataOutputStream(conn.outputStream).use { output -> + fun writeFormField(name: String, value: String) { + output.writeBytes("--$boundary\r\n") + output.writeBytes("Content-Disposition: form-data; name=\"$name\"\r\n\r\n") + output.writeBytes(value) + output.writeBytes("\r\n") + } + + writeFormField("device_uuid", deviceUuid) + + output.writeBytes("--$boundary\r\n") + output.writeBytes("Content-Disposition: form-data; name=\"files\"; filename=\"${file.name}\"\r\n") + output.writeBytes("Content-Type: ${item.mimeType}\r\n\r\n") + + FileInputStream(file).use { input -> + val buffer = ByteArray(STREAM_CHUNK_SIZE) + while (true) { + val read = input.read(buffer) + if (read == -1) break + output.write(buffer, 0, read) + } + } + + output.writeBytes("\r\n") + output.writeBytes("--$boundary--\r\n") + output.flush() + } + + val responseCode = conn.responseCode + val responseText = (if (responseCode in 200..299) conn.inputStream else conn.errorStream) + ?.bufferedReader() + ?.use(BufferedReader::readText) + .orEmpty() + + if (responseCode !in 200..299) return false + val json = if (responseText.isNotBlank()) JSONObject(responseText) else JSONObject() + json.optBoolean("ok", false) + } catch (_: OutOfMemoryError) { + false + } catch (_: Exception) { + false + } finally { + conn?.disconnect() + } + } + + private fun getSelectedItems(): List { + val selected = mutableListOf() + for (i in mediaItems.indices) { + if (binding.mediaListView.isItemChecked(i)) { + selected.add(mediaItems[i]) + } + } + return selected + } + + private fun updateSelectionInfo() { + val selectedCount = getSelectedItems().size + val totalCount = mediaItems.size + binding.selectionInfoText.text = "Selected $selectedCount of $totalCount media file(s)" + } + + private fun getOrCreateDeviceUuid(): String { + val prefs = getPrefs() + val existing = prefs.getString(KEY_DEVICE_UUID, null) + if (!existing.isNullOrBlank()) return existing + + val newUuid = UUID.randomUUID().toString() + prefs.edit().putString(KEY_DEVICE_UUID, newUuid).apply() + return newUuid + } + + private fun buildPhoneLabel(): String { + val manufacturer = Build.MANUFACTURER ?: "Android" + val model = Build.MODEL ?: "Device" + return "$manufacturer $model".trim() + } + + private fun formatBytes(bytes: Long): String { + if (bytes < 1024) return "$bytes B" + val kb = bytes / 1024.0 + if (kb < 1024) return String.format("%.1f KB", kb) + val mb = kb / 1024.0 + if (mb < 1024) return String.format("%.1f MB", mb) + val gb = mb / 1024.0 + return String.format("%.2f GB", gb) + } + + override fun onRequestPermissionsResult( + requestCode: Int, + permissions: Array, + grantResults: IntArray + ) { + super.onRequestPermissionsResult(requestCode, permissions, grantResults) + + if (requestCode == REQ_READ_STORAGE) { + val granted = grantResults.isNotEmpty() && grantResults.all { it == PackageManager.PERMISSION_GRANTED } + if (granted) { + scanCameraMedia() + } else { + binding.statusText.text = "Storage permission denied" + Toast.makeText(this, "Storage permission is required to scan media", Toast.LENGTH_LONG).show() + } + } + } +} +EOF + +echo "===== BUMP VERSION FILES TO v0.3.0 =====" +python3 - << 'PY' +from pathlib import Path +import re + +# build.gradle +p = Path("/home/def/outsidethebox/otb-cloud-android/app/build.gradle") +text = p.read_text() +text = re.sub(r'versionCode\s+\d+', 'versionCode 30', text) +text = re.sub(r'versionName\s+"[^"]+"', 'versionName "0.3.0"', text) +p.write_text(text) + +# AndroidManifest.xml +p = Path("/home/def/outsidethebox/otb-cloud-android/app/src/main/AndroidManifest.xml") +text = p.read_text() +for old in ["OTB Cloud v0.2.2", "OTB Cloud v0.2.1", "OTB Cloud v0.2.0", "OTB Cloud v0.1.9"]: + text = text.replace(f'android:label="{old}"', 'android:label="OTB Cloud v0.3.0"') +p.write_text(text) + +# activity_main.xml +p = Path("/home/def/outsidethebox/otb-cloud-android/app/src/main/res/layout/activity_main.xml") +text = p.read_text() +for old in ["OTB Cloud v0.2.2", "OTB Cloud v0.2.1", "OTB Cloud v0.2.0", "OTB Cloud v0.1.9"]: + text = text.replace(old, "OTB Cloud v0.3.0") +for old in ["Android Backup Client v0.2.2", "Android Backup Client v0.2.1", "Android Backup Client v0.2.0", "Android Backup Client v0.1.9"]: + text = text.replace(old, "Android Backup Client v0.3.0") +p.write_text(text) +print("version files updated") +PY + +echo "===== PREPEND README.md =====" +python3 - << 'PY' +from pathlib import Path + +p = Path("/home/def/outsidethebox/otb-cloud-android/README.md") +existing = p.read_text() if p.exists() else "" +entry = """# OTB Cloud Android Client + +## Version +v0.3.0 + +## v0.3.0 notes +- Reworked upload path to use chunked streaming mode +- Forces per-file HTTP connection teardown +- Keeps batched upload handling +- Aims to eliminate memory buildup during long video runs + +## Notes +- Images: working +- Videos: streaming rewrite under live testing + +""" +if "## v0.3.0 notes" not in existing: + if existing.startswith("# OTB Cloud Android Client"): + lines = existing.splitlines(True) + # remove the old top header only if present so we don't duplicate it badly + if lines: + existing = "".join(lines[1:]).lstrip("\n") + p.write_text(entry + existing) +print("README.md updated") +PY + +echo "===== PREPEND PROJECT_STATE.md =====" +python3 - << 'PY' +from pathlib import Path + +p = Path("/home/def/outsidethebox/otb-cloud-android/PROJECT_STATE.md") +existing = p.read_text() if p.exists() else "" +entry = """## [v0.3.0] - Streaming upload rewrite + +### Upload behavior +- Uses HttpURLConnection chunked streaming mode +- Forces per-file response stream cleanup +- Forces per-file connection disconnect +- Keeps batching: + - videos: 2 + - images: 25 + +### Why +- v0.2.2 still hit OutOfMemoryError during long video runs +- crash remained inside uploadSingleFile() + +### Goal +- stop request-body memory buildup on large video uploads +- improve long-run stability for 100+ video sessions + +### Next +- if stable: raise batch sizes carefully +- if still unstable: move uploads into a foreground service + +""" + +if "## [v0.3.0]" not in existing: + p.write_text(entry + existing) +print("PROJECT_STATE.md updated") +PY + +echo "===== VERIFY SOURCE =====" +grep -n 'setChunkedStreamingMode\|Connection", "close"\|VIDEO_BATCH_SIZE\|IMAGE_BATCH_SIZE\|PAUSE_BETWEEN_FILES_MS\|PAUSE_BETWEEN_BATCHES_MS' \ + /home/def/outsidethebox/otb-cloud-android/app/src/main/java/top/outsidethebox/otbcloud/MainActivity.kt +grep -nE 'versionCode|versionName' /home/def/outsidethebox/otb-cloud-android/app/build.gradle +grep -n 'android:label=' /home/def/outsidethebox/otb-cloud-android/app/src/main/AndroidManifest.xml +sed -n '1,40p' /home/def/outsidethebox/otb-cloud-android/PROJECT_STATE.md +sed -n '1,30p' /home/def/outsidethebox/otb-cloud-android/README.md + +echo "===== BUILD =====" +cd /home/def/outsidethebox/otb-cloud-android || exit 1 +./gradlew clean assembleDebug --no-daemon + +echo "===== COPY APK =====" +cp /home/def/outsidethebox/otb-cloud-android/app/build/outputs/apk/debug/app-debug.apk \ + /home/def/outsidethebox/apk-drop/otb-cloud-v0.3.0.apk + +echo "===== VERIFY APK =====" +aapt dump badging /home/def/outsidethebox/otb-cloud-android/app/build/outputs/apk/debug/app-debug.apk 2>/dev/null | grep version || true + +echo "===== FINAL APK =====" +ls -lh /home/def/outsidethebox/apk-drop/otb-cloud-v0.3.0.apk diff --git a/server b/server new file mode 100644 index 0000000..e69de29