Browse Source

v0.3.0: streaming upload rewrite with per-file teardown

main v0.3.0
def670 2 weeks ago
parent
commit
50637f5baa
  1. 22
      PROJECT_STATE.md
  2. 13
      README.md
  3. 4
      app/build.gradle
  4. 2
      app/src/main/AndroidManifest.xml
  5. 167
      app/src/main/java/top/outsidethebox/otbcloud/MainActivity.kt
  6. 4
      app/src/main/res/layout/activity_main.xml
  7. 0
      client
  8. 811
      patch3.sh
  9. 0
      server

22
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 ## [v0.2.2] - Batched upload stabilization
### Upload behavior ### Upload behavior

13
README.md

@ -1,5 +1,18 @@
# OTB Cloud Android Client # 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. Android uploader for OTB Cloud.
## Version ## Version

4
app/build.gradle

@ -11,8 +11,8 @@ android {
applicationId "top.outsidethebox.otbcloud" applicationId "top.outsidethebox.otbcloud"
minSdk 23 minSdk 23
targetSdk 34 targetSdk 34
versionCode 22 versionCode 30
versionName "0.2.2" versionName "0.3.0"
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
} }

2
app/src/main/AndroidManifest.xml

@ -17,7 +17,7 @@
<application <application
android:allowBackup="true" android:allowBackup="true"
android:icon="@drawable/favicon" android:icon="@drawable/favicon"
android:label="OTB Cloud v0.2.2" android:label="OTB Cloud v0.3.0"
android:supportsRtl="true" android:supportsRtl="true"
android:theme="@style/Theme.OtbCloud"> android:theme="@style/Theme.OtbCloud">

167
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 EXISTS_URL = "https://otb-cloud.outsidethebox.top/api/android/file-exists"
private const val REQ_READ_STORAGE = 2001 private const val REQ_READ_STORAGE = 2001
// v0.3.0 upload rewrite defaults
private const val VIDEO_BATCH_SIZE = 2 private const val VIDEO_BATCH_SIZE = 2
private const val IMAGE_BATCH_SIZE = 25 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_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 { enum class ScanMode {
@ -189,43 +194,50 @@ class MainActivity : AppCompatActivity() {
connectTimeout = 15000 connectTimeout = 15000
readTimeout = 20000 readTimeout = 20000
doOutput = true doOutput = true
useCaches = false
setRequestProperty("Content-Type", "application/json") setRequestProperty("Content-Type", "application/json")
setRequestProperty("Accept", "application/json") setRequestProperty("Accept", "application/json")
setRequestProperty("Connection", "close")
} }
OutputStreamWriter(conn.outputStream, Charsets.UTF_8).use { writer -> try {
writer.write(payload.toString()) OutputStreamWriter(conn.outputStream, Charsets.UTF_8).use { writer ->
writer.flush() writer.write(payload.toString())
} writer.flush()
}
val responseCode = conn.responseCode val responseCode = conn.responseCode
val stream = if (responseCode in 200..299) conn.inputStream else conn.errorStream val responseText = (if (responseCode in 200..299) conn.inputStream else conn.errorStream)
val responseText = stream?.bufferedReader()?.use(BufferedReader::readText).orEmpty() ?.bufferedReader()
val json = if (responseText.isNotBlank()) JSONObject(responseText) else JSONObject() ?.use(BufferedReader::readText)
.orEmpty()
runOnUiThread { val json = if (responseText.isNotBlank()) JSONObject(responseText) else JSONObject()
if (responseCode in 200..299 && json.optBoolean("ok", false)) {
val prefs = getPrefs() runOnUiThread {
prefs.edit() if (responseCode in 200..299 && json.optBoolean("ok", false)) {
.putBoolean(KEY_ACTIVATED, true) val prefs = getPrefs()
.putString(KEY_TOKEN, token) prefs.edit()
.putInt(KEY_DEVICE_ID, json.optInt("device_id", 0)) .putBoolean(KEY_ACTIVATED, true)
.putString(KEY_DEVICE_NAME, json.optString("device_name", "")) .putString(KEY_TOKEN, token)
.putString(KEY_DEVICE_LABEL, json.optString("device_label", "")) .putInt(KEY_DEVICE_ID, json.optInt("device_id", 0))
.putString(KEY_RELATIVE_PATH, json.optString("relative_path", "")) .putString(KEY_DEVICE_NAME, json.optString("device_name", ""))
.apply() .putString(KEY_DEVICE_LABEL, json.optString("device_label", ""))
.putString(KEY_RELATIVE_PATH, json.optString("relative_path", ""))
Toast.makeText(this, "Activation successful", Toast.LENGTH_LONG).show() .apply()
showMediaSection()
} else { Toast.makeText(this, "Activation successful", Toast.LENGTH_LONG).show()
val errorText = json.optString("error", "activation_failed") showMediaSection()
binding.activateButton.isEnabled = true } else {
binding.statusText.text = "Activation failed: $errorText" val errorText = json.optString("error", "activation_failed")
Toast.makeText(this, "Activation failed: $errorText", Toast.LENGTH_LONG).show() 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) { } catch (e: Exception) {
runOnUiThread { runOnUiThread {
binding.activateButton.isEnabled = true binding.activateButton.isEnabled = true
@ -404,13 +416,7 @@ class MainActivity : AppCompatActivity() {
} }
} }
private fun batchSizeForSelection(selected: List<MediaItem>): Int { private fun uploadSelectedMedia(selected: List<MediaItem>) {
val containsVideo = selected.any { it.mimeType.startsWith("video/") }
return if (containsVideo) VIDEO_BATCH_SIZE else IMAGE_BATCH_SIZE
}
private fun uploadSelectedMedia(selected: List<MediaItem>) {
val prefs = getPrefs() val prefs = getPrefs()
val deviceUuid = prefs.getString(KEY_DEVICE_UUID, null) val deviceUuid = prefs.getString(KEY_DEVICE_UUID, null)
@ -466,8 +472,6 @@ private fun uploadSelectedMedia(selected: List<MediaItem>) {
Thread.sleep(PAUSE_BETWEEN_FILES_MS) Thread.sleep(PAUSE_BETWEEN_FILES_MS)
} catch (_: Exception) { } catch (_: Exception) {
} }
System.gc()
} }
if (batchIndex < batches.lastIndex) { if (batchIndex < batches.lastIndex) {
@ -479,7 +483,6 @@ private fun uploadSelectedMedia(selected: List<MediaItem>) {
Thread.sleep(PAUSE_BETWEEN_BATCHES_MS) Thread.sleep(PAUSE_BETWEEN_BATCHES_MS)
} catch (_: Exception) { } catch (_: Exception) {
} }
System.gc()
} }
} }
@ -500,6 +503,7 @@ private fun uploadSelectedMedia(selected: List<MediaItem>) {
} }
private fun serverFileExists(deviceUuid: String, item: MediaItem): Boolean { private fun serverFileExists(deviceUuid: String, item: MediaItem): Boolean {
var conn: HttpURLConnection? = null
return try { return try {
val url = URL( val url = URL(
EXISTS_URL + EXISTS_URL +
@ -508,82 +512,95 @@ private fun uploadSelectedMedia(selected: List<MediaItem>) {
"&size_bytes=" + item.sizeBytes "&size_bytes=" + item.sizeBytes
) )
val conn = (url.openConnection() as HttpURLConnection).apply { conn = (url.openConnection() as HttpURLConnection).apply {
requestMethod = "GET" requestMethod = "GET"
connectTimeout = 15000 connectTimeout = 15000
readTimeout = 20000 readTimeout = 20000
useCaches = false
setRequestProperty("Accept", "application/json") setRequestProperty("Accept", "application/json")
setRequestProperty("Connection", "close")
} }
val responseCode = conn.responseCode val responseCode = conn.responseCode
val stream = if (responseCode in 200..299) conn.inputStream else conn.errorStream val responseText = (if (responseCode in 200..299) conn.inputStream else conn.errorStream)
val responseText = stream?.bufferedReader()?.use(BufferedReader::readText).orEmpty() ?.bufferedReader()
conn.disconnect() ?.use(BufferedReader::readText)
.orEmpty()
if (responseCode !in 200..299) return false if (responseCode !in 200..299) return false
val json = if (responseText.isNotBlank()) JSONObject(responseText) else JSONObject() val json = if (responseText.isNotBlank()) JSONObject(responseText) else JSONObject()
json.optBoolean("exists", false) json.optBoolean("exists", false)
} catch (_: Exception) { } catch (_: Exception) {
false false
} finally {
conn?.disconnect()
} }
} }
private fun uploadSingleFile(deviceUuid: String, item: MediaItem): Boolean { private fun uploadSingleFile(deviceUuid: String, item: MediaItem): Boolean {
var conn: HttpURLConnection? = null
return try { return try {
val boundary = "----OTBCloudBoundary${System.currentTimeMillis()}" val boundary = "----OTBCloudBoundary${System.currentTimeMillis()}"
val file = File(item.path) val file = File(item.path)
if (!file.exists()) return false if (!file.exists()) return false
val url = URL(UPLOAD_URL) val url = URL(UPLOAD_URL)
val conn = (url.openConnection() as HttpURLConnection).apply { conn = (url.openConnection() as HttpURLConnection).apply {
requestMethod = "POST" requestMethod = "POST"
connectTimeout = 30000 connectTimeout = CONNECT_TIMEOUT_MS
readTimeout = 60000 readTimeout = READ_TIMEOUT_MS
doOutput = true doOutput = true
doInput = true
useCaches = false
setChunkedStreamingMode(STREAM_CHUNK_SIZE)
setRequestProperty("Content-Type", "multipart/form-data; boundary=$boundary") setRequestProperty("Content-Type", "multipart/form-data; boundary=$boundary")
setRequestProperty("Accept", "application/json") setRequestProperty("Accept", "application/json")
setRequestProperty("Connection", "close")
} }
val output = DataOutputStream(conn.outputStream) DataOutputStream(conn.outputStream).use { output ->
fun writeFormField(name: String, value: String) {
fun writeFormField(name: String, value: String) { output.writeBytes("--$boundary\r\n")
output.writeBytes("--$boundary\r\n") output.writeBytes("Content-Disposition: form-data; name=\"$name\"\r\n\r\n")
output.writeBytes("Content-Disposition: form-data; name=\"$name\"\r\n\r\n") output.writeBytes(value)
output.writeBytes(value) output.writeBytes("\r\n")
output.writeBytes("\r\n") }
}
writeFormField("device_uuid", deviceUuid)
output.writeBytes("--$boundary\r\n") writeFormField("device_uuid", deviceUuid)
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 -> output.writeBytes("--$boundary\r\n")
val buffer = ByteArray(8192) output.writeBytes("Content-Disposition: form-data; name=\"files\"; filename=\"${file.name}\"\r\n")
while (true) { output.writeBytes("Content-Type: ${item.mimeType}\r\n\r\n")
val read = input.read(buffer)
if (read == -1) break FileInputStream(file).use { input ->
output.write(buffer, 0, read) 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("\r\n")
output.writeBytes("--$boundary--\r\n") output.writeBytes("--$boundary--\r\n")
output.flush() output.flush()
output.close() }
val responseCode = conn.responseCode val responseCode = conn.responseCode
val stream = if (responseCode in 200..299) conn.inputStream else conn.errorStream val responseText = (if (responseCode in 200..299) conn.inputStream else conn.errorStream)
val responseText = stream?.bufferedReader()?.use(BufferedReader::readText).orEmpty() ?.bufferedReader()
?.use(BufferedReader::readText)
conn.disconnect() .orEmpty()
if (responseCode !in 200..299) return false if (responseCode !in 200..299) return false
val json = if (responseText.isNotBlank()) JSONObject(responseText) else JSONObject() val json = if (responseText.isNotBlank()) JSONObject(responseText) else JSONObject()
json.optBoolean("ok", false) json.optBoolean("ok", false)
} catch (_: OutOfMemoryError) {
false
} catch (_: Exception) { } catch (_: Exception) {
false false
} finally {
conn?.disconnect()
} }
} }

4
app/src/main/res/layout/activity_main.xml

@ -15,7 +15,7 @@
android:id="@+id/titleText" android:id="@+id/titleText"
android:layout_width="wrap_content" android:layout_width="wrap_content"
android:layout_height="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:textColor="#FFFFFF"
android:textSize="28sp" android:textSize="28sp"
android:textStyle="bold" android:textStyle="bold"
@ -25,7 +25,7 @@
android:id="@+id/subtitleText" android:id="@+id/subtitleText"
android:layout_width="wrap_content" android:layout_width="wrap_content"
android:layout_height="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:textColor="#B8C7E0"
android:textSize="16sp" android:textSize="16sp"
android:layout_marginTop="8dp" android:layout_marginTop="8dp"

811
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<MediaItem>()
private lateinit var mediaAdapter: ArrayAdapter<String>
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<String>()
)
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<MediaItem>()
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<MediaItem>) {
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<MediaItem> {
val selected = mutableListOf<MediaItem>()
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<out String>,
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
Loading…
Cancel
Save