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