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/build.gradle \ "$BACKUPDIR/app.build.gradle.$(date +%Y%m%d-%H%M%S).bak" cp /home/def/outsidethebox/otb-cloud-android/app/src/main/AndroidManifest.xml \ "$BACKUPDIR/AndroidManifest.xml.$(date +%Y%m%d-%H%M%S).bak" cp /home/def/outsidethebox/otb-cloud-android/app/src/main/java/top/outsidethebox/otbcloud/MainActivity.kt \ "$BACKUPDIR/MainActivity.kt.$(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.$(date +%Y%m%d-%H%M%S).bak" echo "===== WRITE /home/def/outsidethebox/otb-cloud-android/app/src/main/java/top/outsidethebox/otbcloud/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 private const val VIDEO_BATCH_SIZE = 5 private const val IMAGE_BATCH_SIZE = 50 private const val PAUSE_BETWEEN_BATCHES_MS = 800L } 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 setRequestProperty("Content-Type", "application/json") setRequestProperty("Accept", "application/json") } 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() 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() } } 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 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) { 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 batchSize = batchSizeForSelection(selected) 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}" } continue } val ok = uploadSingleFile(deviceUuid, item) if (ok) { uploaded += 1 } else { failed += 1 } } 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 { 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 ) val conn = (url.openConnection() as HttpURLConnection).apply { requestMethod = "GET" connectTimeout = 15000 readTimeout = 20000 setRequestProperty("Accept", "application/json") } 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() if (responseCode !in 200..299) return false val json = if (responseText.isNotBlank()) JSONObject(responseText) else JSONObject() json.optBoolean("exists", false) } catch (_: Exception) { false } } private fun uploadSingleFile(deviceUuid: String, item: MediaItem): Boolean { 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 { requestMethod = "POST" connectTimeout = 30000 readTimeout = 60000 doOutput = true setRequestProperty("Content-Type", "multipart/form-data; boundary=$boundary") setRequestProperty("Accept", "application/json") } 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) 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(8192) 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() 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() if (responseCode !in 200..299) return false val json = if (responseText.isNotBlank()) JSONObject(responseText) else JSONObject() json.optBoolean("ok", false) } catch (_: Exception) { false } } 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 "===== UPDATE /home/def/outsidethebox/otb-cloud-android/app/build.gradle =====" python3 - << 'PY' from pathlib import Path import re p = Path("/home/def/outsidethebox/otb-cloud-android/app/build.gradle") text = p.read_text() text = re.sub(r'versionCode\s+\d+', 'versionCode 21', text) text = re.sub(r'versionName\s+"[^"]+"', 'versionName "0.2.1"', text) p.write_text(text) print("build.gradle updated") PY echo "===== UPDATE /home/def/outsidethebox/otb-cloud-android/app/src/main/AndroidManifest.xml =====" python3 - << 'PY' from pathlib import Path p = Path("/home/def/outsidethebox/otb-cloud-android/app/src/main/AndroidManifest.xml") text = p.read_text() text = text.replace('android:label="OTB Cloud v0.2.0"', 'android:label="OTB Cloud v0.2.1"') text = text.replace('android:label="OTB Cloud v0.1.9"', 'android:label="OTB Cloud v0.2.1"') text = text.replace('android:label="OTB Cloud v0.1.8"', 'android:label="OTB Cloud v0.2.1"') p.write_text(text) print("AndroidManifest.xml updated") PY echo "===== UPDATE /home/def/outsidethebox/otb-cloud-android/app/src/main/res/layout/activity_main.xml =====" python3 - << 'PY' from pathlib import Path p = Path("/home/def/outsidethebox/otb-cloud-android/app/src/main/res/layout/activity_main.xml") text = p.read_text() text = text.replace('OTB Cloud v0.2.0', 'OTB Cloud v0.2.1') text = text.replace('OTB Cloud v0.1.9', 'OTB Cloud v0.2.1') text = text.replace('OTB Cloud v0.1.8', 'OTB Cloud v0.2.1') text = text.replace('Android Backup Client v0.2.0', 'Android Backup Client v0.2.1') text = text.replace('Android Backup Client v0.1.9', 'Android Backup Client v0.2.1') text = text.replace('Android Backup Client v0.1.8', 'Android Backup Client v0.2.1') p.write_text(text) print("activity_main.xml updated") PY echo "===== VERIFY SOURCE =====" grep -Rn --exclude-dir=.gradle --exclude-dir=build --exclude="*.bak*" "0.2.1\|0.2.0\|0.1.9\|0.1.8" /home/def/outsidethebox/otb-cloud-android/app /home/def/outsidethebox/otb-cloud-android/app/build.gradle || true 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.2.1.apk echo "===== VERIFY APK VERSION =====" 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.2.1.apk