Browse Source

v0.2.2 initial commit: android uploader with batching

main
def670 2 weeks ago
commit
e5edc736b7
  1. 7
      .gitignore
  2. 20
      PROJECT_STATE.md
  3. 16
      README.md
  4. 48
      app/build.gradle
  5. 37
      app/src/main/AndroidManifest.xml
  6. 649
      app/src/main/java/top/outsidethebox/otbcloud/MainActivity.kt
  7. BIN
      app/src/main/res/drawable/favicon.png
  8. 156
      app/src/main/res/layout/activity_main.xml
  9. 6
      app/src/main/res/values/colors.xml
  10. 3
      app/src/main/res/values/strings.xml
  11. 11
      app/src/main/res/values/themes.xml
  12. 4
      build.gradle
  13. 4
      gradle.properties
  14. BIN
      gradle/wrapper/gradle-wrapper.jar
  15. 7
      gradle/wrapper/gradle-wrapper.properties
  16. 249
      gradlew
  17. 92
      gradlew.bat
  18. 718
      patch.sh
  19. 121
      patch1.sh
  20. 182
      patch2.sh
  21. 18
      settings.gradle

7
.gitignore vendored

@ -0,0 +1,7 @@
.gradle/
build/
app/build/
local.properties
*.apk
*.log
*.bak

20
PROJECT_STATE.md

@ -0,0 +1,20 @@
## [v0.2.2] - Batched upload stabilization
### Upload behavior
- Video batch size: 2
- Image batch size: 25
- Pause between files: 300ms
- Pause between batches: 1500ms
- GC assist between uploads
### Current state
- APK version: v0.2.2
- Server routing aligned to:
- originals/images
- originals/video
### Known limitation
- Long video upload runs can still hit OutOfMemoryError
### Next target
- v0.3.0 streaming upload rewrite

16
README.md

@ -0,0 +1,16 @@
# OTB Cloud Android Client
Android uploader for OTB Cloud.
## Version
v0.2.2
## Features
- Scan images and videos
- Multi-select upload
- Batched upload throttling
- Server-side duplicate check
## Notes
- Images: working
- Videos: improved, still under stability work

48
app/build.gradle

@ -0,0 +1,48 @@
plugins {
id 'com.android.application'
id 'org.jetbrains.kotlin.android'
}
android {
namespace 'top.outsidethebox.otbcloud'
compileSdk 34
defaultConfig {
applicationId "top.outsidethebox.otbcloud"
minSdk 23
targetSdk 34
versionCode 22
versionName "0.2.2"
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
}
buildTypes {
release {
minifyEnabled false
}
debug {
minifyEnabled false
}
}
compileOptions {
sourceCompatibility JavaVersion.VERSION_17
targetCompatibility JavaVersion.VERSION_17
}
kotlinOptions {
jvmTarget = '17'
}
buildFeatures {
viewBinding true
}
}
dependencies {
implementation 'androidx.core:core-ktx:1.13.1'
implementation 'androidx.appcompat:appcompat:1.7.0'
implementation 'com.google.android.material:material:1.12.0'
implementation 'androidx.constraintlayout:constraintlayout:2.1.4'
}

37
app/src/main/AndroidManifest.xml

@ -0,0 +1,37 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
package="top.outsidethebox.otbcloud">
<!-- REQUIRED -->
<uses-permission android:name="android.permission.INTERNET" />
<!-- ANDROID 12 AND LOWER -->
<uses-permission
android:name="android.permission.READ_EXTERNAL_STORAGE"
android:maxSdkVersion="32" />
<!-- ANDROID 13+ -->
<uses-permission android:name="android.permission.READ_MEDIA_IMAGES" />
<uses-permission android:name="android.permission.READ_MEDIA_VIDEO" />
<application
android:allowBackup="true"
android:icon="@drawable/favicon"
android:label="OTB Cloud v0.2.2"
android:supportsRtl="true"
android:theme="@style/Theme.OtbCloud">
<activity
android:name=".MainActivity"
android:exported="true">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
</application>
</manifest>

649
app/src/main/java/top/outsidethebox/otbcloud/MainActivity.kt

@ -0,0 +1,649 @@
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
private const val VIDEO_BATCH_SIZE = 2
private const val IMAGE_BATCH_SIZE = 25
private const val PAUSE_BETWEEN_BATCHES_MS = 1500L
private const val PAUSE_BETWEEN_FILES_MS = 300L
}
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
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<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 batchSizeForSelection(selected: List<MediaItem>): Int {
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 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) {
}
System.gc()
}
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) {
}
System.gc()
}
}
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<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()
}
}
}
}

BIN
app/src/main/res/drawable/favicon.png

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

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

@ -0,0 +1,156 @@
<?xml version="1.0" encoding="utf-8"?>
<ScrollView xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:fillViewport="true"
android:background="#081225">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:padding="24dp">
<TextView
android:id="@+id/titleText"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="OTB Cloud v0.2.2"
android:textColor="#FFFFFF"
android:textSize="28sp"
android:textStyle="bold"
android:layout_marginTop="24dp" />
<TextView
android:id="@+id/subtitleText"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="Android Backup Client v0.2.2"
android:textColor="#B8C7E0"
android:textSize="16sp"
android:layout_marginTop="8dp"
android:layout_marginBottom="24dp" />
<LinearLayout
android:id="@+id/activationSection"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical">
<EditText
android:id="@+id/tokenInput"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:hint="Enter Activation Token"
android:inputType="text"
android:textColor="#FFFFFF"
android:textColorHint="#8FA1C0"
android:backgroundTint="#7CC7F5" />
<Button
android:id="@+id/activateButton"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="Activate"
android:layout_marginTop="16dp" />
</LinearLayout>
<LinearLayout
android:id="@+id/mediaSection"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:visibility="gone">
<TextView
android:id="@+id/deviceInfoText"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="Activated device"
android:textColor="#B8C7E0"
android:textSize="14sp"
android:layout_marginBottom="16dp" />
<Button
android:id="@+id/resetActivationButton"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="Use New Token"
android:layout_marginBottom="12dp" />
<Button
android:id="@+id/scanButton"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="Mode: Images + Videos" />
<Button
android:id="@+id/runScanButton"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="Run Scan"
android:layout_marginTop="8dp" />
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal"
android:layout_marginTop="12dp"
android:layout_marginBottom="12dp">
<Button
android:id="@+id/selectAllButton"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:text="Select All" />
<Space
android:layout_width="8dp"
android:layout_height="1dp" />
<Button
android:id="@+id/clearAllButton"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:text="Clear All" />
</LinearLayout>
<Button
android:id="@+id/uploadSelectedButton"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="Upload Selected" />
<TextView
android:id="@+id/selectionInfoText"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="No media scanned yet"
android:textColor="#B8C7E0"
android:textSize="14sp"
android:layout_marginTop="12dp"
android:layout_marginBottom="12dp" />
<ListView
android:id="@+id/mediaListView"
android:layout_width="match_parent"
android:layout_height="420dp"
android:choiceMode="multipleChoice"
android:background="#10203B"
android:divider="#22385C"
android:dividerHeight="1dp" />
</LinearLayout>
<TextView
android:id="@+id/statusText"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="Ready"
android:textColor="#B8C7E0"
android:textSize="14sp"
android:layout_marginTop="16dp" />
</LinearLayout>
</ScrollView>

6
app/src/main/res/values/colors.xml

@ -0,0 +1,6 @@
<resources>
<color name="otb_bg">#081225</color>
<color name="otb_text">#FFFFFF</color>
<color name="otb_subtle">#B8C7E0</color>
<color name="otb_accent">#7CC7F5</color>
</resources>

3
app/src/main/res/values/strings.xml

@ -0,0 +1,3 @@
<resources>
<string name="app_name">OTB Cloud</string>
</resources>

11
app/src/main/res/values/themes.xml

@ -0,0 +1,11 @@
<resources xmlns:tools="http://schemas.android.com/tools">
<style name="Theme.OtbCloud" parent="Theme.MaterialComponents.DayNight.NoActionBar">
<item name="colorPrimary">@color/otb_accent</item>
<item name="colorPrimaryVariant">@color/otb_accent</item>
<item name="colorOnPrimary">@color/otb_text</item>
<item name="android:statusBarColor">@color/otb_bg</item>
<item name="android:navigationBarColor">@color/otb_bg</item>
<item name="colorSurface">@color/otb_bg</item>
<item name="android:windowBackground">@color/otb_bg</item>
</style>
</resources>

4
build.gradle

@ -0,0 +1,4 @@
plugins {
id 'com.android.application' version '8.4.2' apply false
id 'org.jetbrains.kotlin.android' version '1.9.24' apply false
}

4
gradle.properties

@ -0,0 +1,4 @@
org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8
android.useAndroidX=true
android.nonTransitiveRClass=true
kotlin.code.style=official

BIN
gradle/wrapper/gradle-wrapper.jar vendored

Binary file not shown.

7
gradle/wrapper/gradle-wrapper.properties vendored

@ -0,0 +1,7 @@
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-8.6-bin.zip
networkTimeout=10000
validateDistributionUrl=true
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists

249
gradlew vendored

@ -0,0 +1,249 @@
#!/bin/sh
#
# Copyright © 2015-2021 the original authors.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# https://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
#
##############################################################################
#
# Gradle start up script for POSIX generated by Gradle.
#
# Important for running:
#
# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is
# noncompliant, but you have some other compliant shell such as ksh or
# bash, then to run this script, type that shell name before the whole
# command line, like:
#
# ksh Gradle
#
# Busybox and similar reduced shells will NOT work, because this script
# requires all of these POSIX shell features:
# * functions;
# * expansions «$var», «${var}», «${var:-default}», «${var+SET}»,
# «${var#prefix}», «${var%suffix}», and «$( cmd )»;
# * compound commands having a testable exit status, especially «case»;
# * various built-in commands including «command», «set», and «ulimit».
#
# Important for patching:
#
# (2) This script targets any POSIX shell, so it avoids extensions provided
# by Bash, Ksh, etc; in particular arrays are avoided.
#
# The "traditional" practice of packing multiple parameters into a
# space-separated string is a well documented source of bugs and security
# problems, so this is (mostly) avoided, by progressively accumulating
# options in "$@", and eventually passing that to Java.
#
# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS,
# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly;
# see the in-line comments for details.
#
# There are tweaks for specific operating systems such as AIX, CygWin,
# Darwin, MinGW, and NonStop.
#
# (3) This script is generated from the Groovy template
# https://github.com/gradle/gradle/blob/HEAD/subprojects/plugins/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt
# within the Gradle project.
#
# You can find Gradle at https://github.com/gradle/gradle/.
#
##############################################################################
# Attempt to set APP_HOME
# Resolve links: $0 may be a link
app_path=$0
# Need this for daisy-chained symlinks.
while
APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path
[ -h "$app_path" ]
do
ls=$( ls -ld "$app_path" )
link=${ls#*' -> '}
case $link in #(
/*) app_path=$link ;; #(
*) app_path=$APP_HOME$link ;;
esac
done
# This is normally unused
# shellcheck disable=SC2034
APP_BASE_NAME=${0##*/}
# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036)
APP_HOME=$( cd "${APP_HOME:-./}" > /dev/null && pwd -P ) || exit
# Use the maximum available, or set MAX_FD != -1 to use that value.
MAX_FD=maximum
warn () {
echo "$*"
} >&2
die () {
echo
echo "$*"
echo
exit 1
} >&2
# OS specific support (must be 'true' or 'false').
cygwin=false
msys=false
darwin=false
nonstop=false
case "$( uname )" in #(
CYGWIN* ) cygwin=true ;; #(
Darwin* ) darwin=true ;; #(
MSYS* | MINGW* ) msys=true ;; #(
NONSTOP* ) nonstop=true ;;
esac
CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar
# Determine the Java command to use to start the JVM.
if [ -n "$JAVA_HOME" ] ; then
if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
# IBM's JDK on AIX uses strange locations for the executables
JAVACMD=$JAVA_HOME/jre/sh/java
else
JAVACMD=$JAVA_HOME/bin/java
fi
if [ ! -x "$JAVACMD" ] ; then
die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
Please set the JAVA_HOME variable in your environment to match the
location of your Java installation."
fi
else
JAVACMD=java
if ! command -v java >/dev/null 2>&1
then
die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
Please set the JAVA_HOME variable in your environment to match the
location of your Java installation."
fi
fi
# Increase the maximum file descriptors if we can.
if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then
case $MAX_FD in #(
max*)
# In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked.
# shellcheck disable=SC2039,SC3045
MAX_FD=$( ulimit -H -n ) ||
warn "Could not query maximum file descriptor limit"
esac
case $MAX_FD in #(
'' | soft) :;; #(
*)
# In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked.
# shellcheck disable=SC2039,SC3045
ulimit -n "$MAX_FD" ||
warn "Could not set maximum file descriptor limit to $MAX_FD"
esac
fi
# Collect all arguments for the java command, stacking in reverse order:
# * args from the command line
# * the main class name
# * -classpath
# * -D...appname settings
# * --module-path (only if needed)
# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables.
# For Cygwin or MSYS, switch paths to Windows format before running java
if "$cygwin" || "$msys" ; then
APP_HOME=$( cygpath --path --mixed "$APP_HOME" )
CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" )
JAVACMD=$( cygpath --unix "$JAVACMD" )
# Now convert the arguments - kludge to limit ourselves to /bin/sh
for arg do
if
case $arg in #(
-*) false ;; # don't mess with options #(
/?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath
[ -e "$t" ] ;; #(
*) false ;;
esac
then
arg=$( cygpath --path --ignore --mixed "$arg" )
fi
# Roll the args list around exactly as many times as the number of
# args, so each arg winds up back in the position where it started, but
# possibly modified.
#
# NB: a `for` loop captures its iteration list before it begins, so
# changing the positional parameters here affects neither the number of
# iterations, nor the values presented in `arg`.
shift # remove old arg
set -- "$@" "$arg" # push replacement arg
done
fi
# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
# Collect all arguments for the java command:
# * DEFAULT_JVM_OPTS, JAVA_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments,
# and any embedded shellness will be escaped.
# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be
# treated as '${Hostname}' itself on the command line.
set -- \
"-Dorg.gradle.appname=$APP_BASE_NAME" \
-classpath "$CLASSPATH" \
org.gradle.wrapper.GradleWrapperMain \
"$@"
# Stop when "xargs" is not available.
if ! command -v xargs >/dev/null 2>&1
then
die "xargs is not available"
fi
# Use "xargs" to parse quoted args.
#
# With -n1 it outputs one arg per line, with the quotes and backslashes removed.
#
# In Bash we could simply go:
#
# readarray ARGS < <( xargs -n1 <<<"$var" ) &&
# set -- "${ARGS[@]}" "$@"
#
# but POSIX shell has neither arrays nor command substitution, so instead we
# post-process each arg (as a line of input to sed) to backslash-escape any
# character that might be a shell metacharacter, then use eval to reverse
# that process (while maintaining the separation between arguments), and wrap
# the whole thing up as a single "set" statement.
#
# This will of course break if any of these variables contains a newline or
# an unmatched quote.
#
eval "set -- $(
printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" |
xargs -n1 |
sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' |
tr '\n' ' '
)" '"$@"'
exec "$JAVACMD" "$@"

92
gradlew.bat vendored

@ -0,0 +1,92 @@
@rem
@rem Copyright 2015 the original author or authors.
@rem
@rem Licensed under the Apache License, Version 2.0 (the "License");
@rem you may not use this file except in compliance with the License.
@rem You may obtain a copy of the License at
@rem
@rem https://www.apache.org/licenses/LICENSE-2.0
@rem
@rem Unless required by applicable law or agreed to in writing, software
@rem distributed under the License is distributed on an "AS IS" BASIS,
@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
@rem See the License for the specific language governing permissions and
@rem limitations under the License.
@rem
@if "%DEBUG%"=="" @echo off
@rem ##########################################################################
@rem
@rem Gradle startup script for Windows
@rem
@rem ##########################################################################
@rem Set local scope for the variables with windows NT shell
if "%OS%"=="Windows_NT" setlocal
set DIRNAME=%~dp0
if "%DIRNAME%"=="" set DIRNAME=.
@rem This is normally unused
set APP_BASE_NAME=%~n0
set APP_HOME=%DIRNAME%
@rem Resolve any "." and ".." in APP_HOME to make it shorter.
for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi
@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m"
@rem Find java.exe
if defined JAVA_HOME goto findJavaFromJavaHome
set JAVA_EXE=java.exe
%JAVA_EXE% -version >NUL 2>&1
if %ERRORLEVEL% equ 0 goto execute
echo. 1>&2
echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2
echo. 1>&2
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
echo location of your Java installation. 1>&2
goto fail
:findJavaFromJavaHome
set JAVA_HOME=%JAVA_HOME:"=%
set JAVA_EXE=%JAVA_HOME%/bin/java.exe
if exist "%JAVA_EXE%" goto execute
echo. 1>&2
echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2
echo. 1>&2
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
echo location of your Java installation. 1>&2
goto fail
:execute
@rem Setup the command line
set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar
@rem Execute Gradle
"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %*
:end
@rem End local scope for the variables with windows NT shell
if %ERRORLEVEL% equ 0 goto mainEnd
:fail
rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
rem the _cmd.exe /c_ return code!
set EXIT_CODE=%ERRORLEVEL%
if %EXIT_CODE% equ 0 set EXIT_CODE=1
if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE%
exit /b %EXIT_CODE%
:mainEnd
if "%OS%"=="Windows_NT" endlocal
:omega

718
patch.sh

@ -0,0 +1,718 @@
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<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
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<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
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<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 batchSizeForSelection(selected: List<MediaItem>): Int {
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 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<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 "===== 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

121
patch1.sh

@ -0,0 +1,121 @@
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 "===== PATCH /home/def/outsidethebox/otb-cloud-android/app/src/main/java/top/outsidethebox/otbcloud/MainActivity.kt ====="
python3 - << 'PY'
from pathlib import Path
p = Path("/home/def/outsidethebox/otb-cloud-android/app/src/main/java/top/outsidethebox/otbcloud/MainActivity.kt")
text = p.read_text()
text = text.replace('private const val VIDEO_BATCH_SIZE = 5', 'private const val VIDEO_BATCH_SIZE = 2')
text = text.replace('private const val IMAGE_BATCH_SIZE = 50', 'private const val IMAGE_BATCH_SIZE = 25')
text = text.replace('private const val PAUSE_BETWEEN_BATCHES_MS = 800L', 'private const val PAUSE_BETWEEN_BATCHES_MS = 1500L')
old = '''
val ok = uploadSingleFile(deviceUuid, item)
if (ok) {
uploaded += 1
} else {
failed += 1
}
'''
new = '''
val ok = uploadSingleFile(deviceUuid, item)
if (ok) {
uploaded += 1
} else {
failed += 1
}
try {
Thread.sleep(300L)
} catch (_: Exception) {
}
System.gc()
'''
if old not in text:
raise SystemExit("FAILED: upload loop block not found in MainActivity.kt")
text = text.replace(old, new, 1)
p.write_text(text)
print("MainActivity.kt patched")
PY
echo "===== PATCH /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 22', text)
text = re.sub(r'versionName\s+"[^"]+"', 'versionName "0.2.2"', text)
p.write_text(text)
print("build.gradle updated")
PY
echo "===== PATCH /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()
for old in ['OTB Cloud v0.2.1', 'OTB Cloud v0.2.0', 'OTB Cloud v0.1.9', 'OTB Cloud v0.1.8']:
text = text.replace(f'android:label="{old}"', 'android:label="OTB Cloud v0.2.2"')
p.write_text(text)
print("AndroidManifest.xml updated")
PY
echo "===== PATCH /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()
for old in ['OTB Cloud v0.2.1', 'OTB Cloud v0.2.0', 'OTB Cloud v0.1.9', 'OTB Cloud v0.1.8']:
text = text.replace(old, 'OTB Cloud v0.2.2')
for old in ['Android Backup Client v0.2.1', 'Android Backup Client v0.2.0', 'Android Backup Client v0.1.9', 'Android Backup Client v0.1.8']:
text = text.replace(old, 'Android Backup Client v0.2.2')
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.2\|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.2.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.2.apk

182
patch2.sh

@ -0,0 +1,182 @@
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.$(date +%Y%m%d-%H%M%S).bak"
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/res/layout/activity_main.xml \
"$BACKUPDIR/activity_main.xml.$(date +%Y%m%d-%H%M%S).bak"
echo "===== PATCH MainActivity.kt ====="
python3 - << 'PY'
from pathlib import Path
import re
p = Path("/home/def/outsidethebox/otb-cloud-android/app/src/main/java/top/outsidethebox/otbcloud/MainActivity.kt")
text = p.read_text()
text = re.sub(r'private const val VIDEO_BATCH_SIZE = \d+', 'private const val VIDEO_BATCH_SIZE = 2', text)
text = re.sub(r'private const val IMAGE_BATCH_SIZE = \d+', 'private const val IMAGE_BATCH_SIZE = 25', text)
text = re.sub(r'private const val PAUSE_BETWEEN_BATCHES_MS = \d+L', 'private const val PAUSE_BETWEEN_BATCHES_MS = 1500L', text)
if 'private const val PAUSE_BETWEEN_FILES_MS' not in text:
text = text.replace(
'private const val PAUSE_BETWEEN_BATCHES_MS = 1500L',
'private const val PAUSE_BETWEEN_BATCHES_MS = 1500L\n private const val PAUSE_BETWEEN_FILES_MS = 300L'
)
pattern = re.compile(
r'private fun uploadSelectedMedia\(selected: List<MediaItem>\) \{.*?\n \}',
re.S
)
replacement = '''
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) {
}
System.gc()
}
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) {
}
System.gc()
}
}
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()
}
}
}'''
new_text, count = pattern.subn(replacement, text, count=1)
if count != 1:
raise SystemExit("FAILED: could not replace uploadSelectedMedia function")
p.write_text(new_text)
print("MainActivity.kt updated")
PY
echo "===== PATCH VERSION FILES TO v0.2.2 ====="
python3 - << 'PY'
from pathlib import Path
import re
files = [
Path("/home/def/outsidethebox/otb-cloud-android/app/build.gradle"),
Path("/home/def/outsidethebox/otb-cloud-android/app/src/main/AndroidManifest.xml"),
Path("/home/def/outsidethebox/otb-cloud-android/app/src/main/res/layout/activity_main.xml"),
]
for p in files:
text = p.read_text()
text = re.sub(r'versionCode\s+\d+', 'versionCode 22', text)
text = re.sub(r'versionName\s+"[^"]+"', 'versionName "0.2.2"', text)
text = text.replace('OTB Cloud v0.2.1', 'OTB Cloud v0.2.2')
text = text.replace('OTB Cloud v0.2.0', 'OTB Cloud v0.2.2')
text = text.replace('OTB Cloud v0.1.9', 'OTB Cloud v0.2.2')
text = text.replace('Android Backup Client v0.2.1', 'Android Backup Client v0.2.2')
text = text.replace('Android Backup Client v0.2.0', 'Android Backup Client v0.2.2')
text = text.replace('Android Backup Client v0.1.9', 'Android Backup Client v0.2.2')
p.write_text(text)
print("Version files updated")
PY
echo "===== VERIFY SOURCE ====="
grep -n 'VIDEO_BATCH_SIZE\|IMAGE_BATCH_SIZE\|PAUSE_BETWEEN_BATCHES_MS\|PAUSE_BETWEEN_FILES_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
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.2.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.2.2.apk

18
settings.gradle

@ -0,0 +1,18 @@
pluginManagement {
repositories {
google()
mavenCentral()
gradlePluginPortal()
}
}
dependencyResolutionManagement {
repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
repositories {
google()
mavenCentral()
}
}
rootProject.name = "otb-cloud-android"
include(":app")
Loading…
Cancel
Save