From e5edc736b7afe1ce144d37c2bf1ed33838c996f3 Mon Sep 17 00:00:00 2001 From: def670 Date: Wed, 22 Apr 2026 22:04:26 -0400 Subject: [PATCH] v0.2.2 initial commit: android uploader with batching --- .gitignore | 7 + PROJECT_STATE.md | 20 + README.md | 16 + app/build.gradle | 48 ++ app/src/main/AndroidManifest.xml | 37 + .../outsidethebox/otbcloud/MainActivity.kt | 649 ++++++++++++++++ app/src/main/res/drawable/favicon.png | Bin 0 -> 13854 bytes app/src/main/res/layout/activity_main.xml | 156 ++++ app/src/main/res/values/colors.xml | 6 + app/src/main/res/values/strings.xml | 3 + app/src/main/res/values/themes.xml | 11 + build.gradle | 4 + gradle.properties | 4 + gradle/wrapper/gradle-wrapper.jar | Bin 0 -> 43453 bytes gradle/wrapper/gradle-wrapper.properties | 7 + gradlew | 249 ++++++ gradlew.bat | 92 +++ patch.sh | 718 ++++++++++++++++++ patch1.sh | 121 +++ patch2.sh | 182 +++++ settings.gradle | 18 + 21 files changed, 2348 insertions(+) create mode 100644 .gitignore create mode 100644 PROJECT_STATE.md create mode 100644 README.md create mode 100644 app/build.gradle create mode 100644 app/src/main/AndroidManifest.xml create mode 100644 app/src/main/java/top/outsidethebox/otbcloud/MainActivity.kt create mode 100644 app/src/main/res/drawable/favicon.png create mode 100644 app/src/main/res/layout/activity_main.xml create mode 100644 app/src/main/res/values/colors.xml create mode 100644 app/src/main/res/values/strings.xml create mode 100644 app/src/main/res/values/themes.xml create mode 100644 build.gradle create mode 100644 gradle.properties create mode 100644 gradle/wrapper/gradle-wrapper.jar create mode 100644 gradle/wrapper/gradle-wrapper.properties create mode 100755 gradlew create mode 100644 gradlew.bat create mode 100755 patch.sh create mode 100755 patch1.sh create mode 100755 patch2.sh create mode 100644 settings.gradle 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 0000000000000000000000000000000000000000..4f0f6bf7cfbda9e5fff2d802dc372943d1630beb GIT binary patch literal 13854 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 Qvs+*?5{ly0q6UHg2R%YkvH$=8 literal 0 HcmV?d00001 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 @@ + + + + + + + + + + + + + +