commit e5edc736b7afe1ce144d37c2bf1ed33838c996f3 Author: def670 Date: Wed Apr 22 22:04:26 2026 -0400 v0.2.2 initial commit: android uploader with batching diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..9f82caf --- /dev/null +++ b/.gitignore @@ -0,0 +1,7 @@ +.gradle/ +build/ +app/build/ +local.properties +*.apk +*.log +*.bak diff --git a/PROJECT_STATE.md b/PROJECT_STATE.md new file mode 100644 index 0000000..f753850 --- /dev/null +++ b/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 diff --git a/README.md b/README.md new file mode 100644 index 0000000..5cb4e95 --- /dev/null +++ b/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 diff --git a/app/build.gradle b/app/build.gradle new file mode 100644 index 0000000..05e02bf --- /dev/null +++ b/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' +} diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml new file mode 100644 index 0000000..46250aa --- /dev/null +++ b/app/src/main/AndroidManifest.xml @@ -0,0 +1,37 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/java/top/outsidethebox/otbcloud/MainActivity.kt b/app/src/main/java/top/outsidethebox/otbcloud/MainActivity.kt new file mode 100644 index 0000000..09370f3 --- /dev/null +++ b/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() + 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 = 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() + ) + 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 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 { + 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() + } + } + } +} diff --git a/app/src/main/res/drawable/favicon.png b/app/src/main/res/drawable/favicon.png new file mode 100644 index 0000000..4f0f6bf Binary files /dev/null and b/app/src/main/res/drawable/favicon.png differ diff --git a/app/src/main/res/layout/activity_main.xml b/app/src/main/res/layout/activity_main.xml new file mode 100644 index 0000000..b59fa87 --- /dev/null +++ b/app/src/main/res/layout/activity_main.xml @@ -0,0 +1,156 @@ + + + + + + + + + + + + + +