diff --git a/README.md b/README.md index 536aae6..1553854 100644 --- a/README.md +++ b/README.md @@ -16,7 +16,9 @@ A modern, high-contrast **KDE Plasma System Tray Uploader** and desktop integrat - **Images**: Fast native scaling via PySide6. - **Videos** (`.mp4`, `.webm`, etc.): Automatic frame extraction at `00:00:01` via `ffmpeg`. - **Other Files**: Sleek placeholder file badges showing the file extension. -- **Clipboard & Settings GUI**: Direct clipboard image/path uploads, URL dialog uploads, and a built-in Settings dialog for configuring API keys, default tags, visibility, rating, and autostart. +- **Multi-Instance Support**: Configure multiple f0ckm server instances (URL & API Key per instance) and switch between them instantly in Settings or directly from the System Tray menu. +- **Android System Share Integration**: Native Android app (`android/`) with `ACTION_SEND` intent filter for sharing files and URLs directly from any Android app to your f0ckm server. +- **Clipboard & Settings GUI**: Direct clipboard image/path uploads, URL dialog uploads, and a built-in Settings dialog for managing instances, API keys, default tags, visibility, rating, and autostart. --- @@ -83,11 +85,13 @@ To bind a keyboard shortcut to capture a screen region and upload it immediately - **Single Click**: Triggers region screenshot capture & upload. - **Right Click**: Opens context menu for: + - **Instance: ** (Submenu to switch active f0ckm instance) - **Capture Region & Upload** - **Upload File...** - **Upload URL...** - **Upload from Clipboard** - **Settings...** + - **Recent Uploads Gallery** - **Quit** - **Double Click**: Opens the Settings dialog. diff --git a/android/.artifacts/84284f4f-2c3a-492f-975d-19a510802dd0/implementation_plan.artifact.md b/android/.artifacts/84284f4f-2c3a-492f-975d-19a510802dd0/implementation_plan.artifact.md new file mode 100644 index 0000000..5ca4f27 --- /dev/null +++ b/android/.artifacts/84284f4f-2c3a-492f-975d-19a510802dd0/implementation_plan.artifact.md @@ -0,0 +1,23 @@ +# Fix Gradle Configuration Mutation Error + +The project is encountering a `Cannot mutate the dependencies of configuration after the configuration was resolved` error. This is primarily caused by a mismatch between the Android Gradle Plugin (AGP) version (8.2.2) and the Gradle version (9.3.0). AGP 8.2.2 is not fully compatible with the stricter configuration resolution rules in Gradle 9.x. + +## Proposed Changes + +### Build Configuration + +#### [MODIFY] [build.gradle.kts (root)](file:///home/kibi/Projects/f0ckm-uploader/android/build.gradle.kts) +- Upgrade AGP to `9.3.1` to match the Gradle version. +- Upgrade Kotlin to `2.0.0` for better compatibility with newer AGP and Gradle versions. + +#### [MODIFY] [app/build.gradle.kts](file:///home/kibi/Projects/f0ckm-uploader/android/app/build.gradle.kts) +- Explicitly disable `dataBinding` to ensure it doesn't trigger unnecessary configuration resolution, while keeping `viewBinding` enabled. +- Update `kotlinOptions` and `jvmTarget` if necessary (though 1.8 is usually fine, newer AGP might prefer 17). + +#### [MODIFY] [gradle.properties](file:///home/kibi/Projects/f0ckm-uploader/android/gradle.properties) +- Disable Jetifier if not needed, as it can interfere with configuration resolution in newer Gradle versions. + +## Verification Plan + +### Automated Tests +- Run `./gradlew assembleDebug` to verify the build succeeds without the configuration mutation error. diff --git a/android/.gitignore b/android/.gitignore new file mode 100644 index 0000000..2bcb493 --- /dev/null +++ b/android/.gitignore @@ -0,0 +1,38 @@ +# Gradle files +.gradle/ +build/ + +# Android Studio / IntelliJ files +.idea/ +*.iml +*.ipr +*.iws + +# Local configuration +local.properties + +# Log files +*.log + +# Built artifacts +*.apk +*.aar +*.ap_ +*.dex + +# OS generated files +.DS_Store +Thumbs.db + +# Android Captures +captures/ + +# External native builds +.externalNativeBuild +.cxx + +# Kotlin +.kotlin/ + +# AI / IDE Artifacts +.artifacts/ diff --git a/android/README.md b/android/README.md new file mode 100644 index 0000000..98e148c --- /dev/null +++ b/android/README.md @@ -0,0 +1,66 @@ +# fuggloader Android Integration + +This directory contains solutions to integrate **fuggloader** directly into the **Android System Share Menu**. + +## Features +- **Native Android App**: A lightweight wrapper that provides a "Share" target for images, videos, and URLs. +- **Automatic Clipboard**: Copies the generated post link directly to the Android Clipboard upon completion. +- **Configurable Settings**: Store API Endpoint URL, API Key, default rating (`sfw`/`nsfw`/`nsfl`), tags, and visibility. + +### How to Build & Install +1. Open the `android/` directory in **Android Studio**. +2. Connect your Android phone via USB debugging or start an emulator. +3. Build & Run the project (or run `./gradlew assembleDebug` to build `app-debug.apk`). +4. Launch **fuggloader Uploader** on your phone: + - Enter your **API URL** (`https://your-fuggloader-site.com/api/v2/upload`) + - Enter your **API Key** + - Tap **Save Settings** -> **Test Connection**. +5. Open any image/video in your Gallery, or any URL in Chrome -> Tap **Share** -> Select **Upload to fuggloader**! + +--- + +## Option 2: HTTP Shortcuts App (Zero Compilation Needed) + +If you don't want to build an Android app manually, you can use the free open-source app **[HTTP Shortcuts](https://play.google.com/store/apps/details?id=ch.rmy.android.http_shortcuts)** (or from F-Droid). + +### Quick Setup: +1. Install **HTTP Shortcuts** from Google Play or F-Droid. +2. Open **HTTP Shortcuts** -> Tap menu (3 dots) -> **Import / Export** -> **Import from file**. +3. Select `android/http-shortcuts-recipe.json`. +4. Edit the imported shortcut: + - Change `https://YOUR_SITE.com/api/v2/upload` to your API URL. + - Set header `X-Api-Key` to your API Key. +5. Save the shortcut. Now "Upload File to fuggloader" and "Upload URL to fuggloader" will appear in your native Android Share menu! + +--- + +## Option 3: Web Share Target (PWA) + +If you host the fuggloader web frontend, you can add a `share_target` entry to `manifest.json`: + +```json +{ + "name": "fuggloader", + "short_name": "fuggloader", + "start_url": "/", + "display": "standalone", + "share_target": { + "action": "/api/v2/upload", + "method": "POST", + "enctype": "multipart/form-data", + "params": { + "title": "title", + "text": "text", + "url": "url", + "files": [ + { + "name": "file", + "accept": ["image/*", "video/*", "*/*"] + } + ] + } + } +} +``` + +When users visit your fuggloader web app on Android Chrome and tap "Add to Home Screen", Android automatically registers it to the native Share menu. diff --git a/android/app/.gitignore b/android/app/.gitignore new file mode 100644 index 0000000..84c048a --- /dev/null +++ b/android/app/.gitignore @@ -0,0 +1 @@ +/build/ diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts new file mode 100644 index 0000000..3493e8f --- /dev/null +++ b/android/app/build.gradle.kts @@ -0,0 +1,56 @@ +plugins { + id("com.android.application") + id("org.jetbrains.kotlin.android") +} + +android { + namespace = "com.fuggloader.app" + compileSdk = 34 + + defaultConfig { + applicationId = "com.fuggloader.app" + minSdk = 24 + targetSdk = 34 + versionCode = 1 + versionName = "1.0.0" + + testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" + } + + buildTypes { + release { + isMinifyEnabled = false + proguardFiles( + getDefaultProguardFile("proguard-android-optimize.txt"), + "proguard-rules.pro" + ) + } + } + + compileOptions { + sourceCompatibility = JavaVersion.VERSION_17 + targetCompatibility = JavaVersion.VERSION_17 + } + + kotlinOptions { + jvmTarget = "17" + } + + buildFeatures { + viewBinding = true + dataBinding = false + } +} + +dependencies { + implementation("androidx.core:core-ktx:1.12.0") + implementation("androidx.appcompat:appcompat:1.6.1") + implementation("com.google.android.material:material:1.11.0") + implementation("androidx.constraintlayout:constraintlayout:2.1.4") + + // OkHttp for fast streaming uploads + implementation("com.squareup.okhttp3:okhttp:4.12.0") + + // Coroutines for background tasks + implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.7.3") +} diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml new file mode 100644 index 0000000..910fc24 --- /dev/null +++ b/android/app/src/main/AndroidManifest.xml @@ -0,0 +1,63 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/android/app/src/main/java/com/fuggloader/app/MainActivity.kt b/android/app/src/main/java/com/fuggloader/app/MainActivity.kt new file mode 100644 index 0000000..72e447d --- /dev/null +++ b/android/app/src/main/java/com/fuggloader/app/MainActivity.kt @@ -0,0 +1,114 @@ +package com.fuggloader.app + +import android.os.Bundle +import android.util.Log +import android.widget.ArrayAdapter +import android.widget.Toast +import androidx.appcompat.app.AppCompatActivity +import com.fuggloader.app.databinding.ActivityMainBinding +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext + +class MainActivity : AppCompatActivity() { + + private lateinit var binding: ActivityMainBinding + private lateinit var prefs: PreferencesManager + private lateinit var uploader: UploaderService + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + binding = ActivityMainBinding.inflate(layoutInflater) + setContentView(binding.root) + + prefs = PreferencesManager(this) + uploader = UploaderService(prefs) + + setupRatingSpinner() + setupVisibilitySpinner() + loadSettings() + + binding.btnSave.setOnClickListener { + saveSettings() + Toast.makeText(this, "Settings saved successfully!", Toast.LENGTH_SHORT).show() + } + + binding.btnTestConnection.setOnClickListener { + saveSettings() + testConnection() + } + } + + private fun setupRatingSpinner() { + val ratings = arrayOf("None", "SFW (sfw)", "NSFW (nsfw)", "NSFL (nsfl)") + val adapter = ArrayAdapter(this, android.R.layout.simple_spinner_dropdown_item, ratings) + binding.spinnerRating.adapter = adapter + } + + private fun setupVisibilitySpinner() { + val visibilities = arrayOf("Public (0)", "Unlisted (1)", "Private (2)") + val adapter = ArrayAdapter(this, android.R.layout.simple_spinner_dropdown_item, visibilities) + binding.spinnerVisibility.adapter = adapter + } + + private fun loadSettings() { + binding.etApiUrl.setText(prefs.apiUrl) + binding.etApiKey.setText(prefs.apiKey) + binding.etTags.setText(prefs.defaultTags) + + when (prefs.defaultRating) { + "sfw" -> binding.spinnerRating.setSelection(1) + "nsfw" -> binding.spinnerRating.setSelection(2) + "nsfl" -> binding.spinnerRating.setSelection(3) + else -> binding.spinnerRating.setSelection(0) + } + + when (prefs.defaultVisibility) { + "1" -> binding.spinnerVisibility.setSelection(1) + "2" -> binding.spinnerVisibility.setSelection(2) + else -> binding.spinnerVisibility.setSelection(0) + } + } + + private fun saveSettings() { + prefs.apiUrl = binding.etApiUrl.text.toString().trim() + prefs.apiKey = binding.etApiKey.text.toString().trim() + prefs.defaultTags = binding.etTags.text.toString().trim() + + prefs.defaultRating = when (binding.spinnerRating.selectedItemPosition) { + 1 -> "sfw" + 2 -> "nsfw" + 3 -> "nsfl" + else -> "none" + } + + prefs.defaultVisibility = binding.spinnerVisibility.selectedItemPosition.toString() + } + + private fun testConnection() { + if (!prefs.isConfigured()) { + Toast.makeText(this, "Please enter both API URL and API Key", Toast.LENGTH_LONG).show() + return + } + + binding.btnTestConnection.isEnabled = false + binding.btnTestConnection.text = "Testing..." + + CoroutineScope(Dispatchers.Main).launch { + val result = uploader.testConnection() + binding.btnTestConnection.isEnabled = true + binding.btnTestConnection.text = "Test" + + result.fold( + onSuccess = { msg -> + Toast.makeText(this@MainActivity, "Connection successful!\n$msg", Toast.LENGTH_LONG).show() + }, + onFailure = { error -> + Log.e("MainActivity", "Test connection failed", error) + Toast.makeText(this@MainActivity, "Connection failed: ${error.message}", Toast.LENGTH_LONG).show() + } + ) + } + } +} diff --git a/android/app/src/main/java/com/fuggloader/app/PreferencesManager.kt b/android/app/src/main/java/com/fuggloader/app/PreferencesManager.kt new file mode 100644 index 0000000..f3bbb45 --- /dev/null +++ b/android/app/src/main/java/com/fuggloader/app/PreferencesManager.kt @@ -0,0 +1,32 @@ +package com.fuggloader.app + +import android.content.Context +import android.content.SharedPreferences + +class PreferencesManager(context: Context) { + private val prefs: SharedPreferences = context.getSharedPreferences("fuggloader_prefs", Context.MODE_PRIVATE) + + var apiUrl: String + get() = prefs.getString("api_url", "") ?: "" + set(value) = prefs.edit().putString("api_url", value).apply() + + var apiKey: String + get() = prefs.getString("api_key", "") ?: "" + set(value) = prefs.edit().putString("api_key", value).apply() + + var defaultRating: String + get() = prefs.getString("default_rating", "none") ?: "none" + set(value) = prefs.edit().putString("default_rating", value).apply() + + var defaultTags: String + get() = prefs.getString("default_tags", "android,upload") ?: "android,upload" + set(value) = prefs.edit().putString("default_tags", value).apply() + + var defaultVisibility: String + get() = prefs.getString("default_visibility", "0") ?: "0" + set(value) = prefs.edit().putString("default_visibility", value).apply() + + fun isConfigured(): Boolean { + return apiUrl.isNotBlank() && apiKey.isNotBlank() + } +} diff --git a/android/app/src/main/java/com/fuggloader/app/ShareActivity.kt b/android/app/src/main/java/com/fuggloader/app/ShareActivity.kt new file mode 100644 index 0000000..a7284e7 --- /dev/null +++ b/android/app/src/main/java/com/fuggloader/app/ShareActivity.kt @@ -0,0 +1,184 @@ +package com.fuggloader.app + +import android.content.ClipData +import android.content.ClipboardManager +import android.content.Context +import android.content.Intent +import android.net.Uri +import android.os.Bundle +import android.view.View +import android.widget.Toast +import androidx.appcompat.app.AppCompatActivity +import com.fuggloader.app.databinding.ActivityShareBinding +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch + +class ShareActivity : AppCompatActivity() { + + private lateinit var binding: ActivityShareBinding + private lateinit var prefs: PreferencesManager + private lateinit var uploader: UploaderService + + private var uploadedUrl: String? = null + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + binding = ActivityShareBinding.inflate(layoutInflater) + setContentView(binding.root) + + prefs = PreferencesManager(this) + uploader = UploaderService(prefs) + + if (!prefs.isConfigured()) { + Toast.makeText(this, "fuggloader is not configured. Please open app settings first.", Toast.LENGTH_LONG).show() + val intent = Intent(this, MainActivity::class.java) + startActivity(intent) + finish() + return + } + + binding.btnCopy.setOnClickListener { + uploadedUrl?.let { url -> + copyToClipboard(url) + Toast.makeText(this, "URL copied to clipboard!", Toast.LENGTH_SHORT).show() + } + } + + binding.btnOpen.setOnClickListener { + uploadedUrl?.let { url -> + val browserIntent = Intent(Intent.ACTION_VIEW, Uri.parse(url)) + startActivity(browserIntent) + finish() + } + } + + binding.btnClose.setOnClickListener { + finish() + } + + handleShareIntent(intent) + } + + private fun handleShareIntent(intent: Intent) { + val action = intent.action + val type = intent.type + + if (Intent.ACTION_SEND == action && type != null) { + if ("text/plain" == type) { + val sharedText = intent.getStringExtra(Intent.EXTRA_TEXT) + if (!sharedText.isNull_or_blank_url()) { + val targetUrl = extractUrl(sharedText!!) + processUrlUpload(targetUrl) + } else { + val streamUri = intent.getParcelableExtra(Intent.EXTRA_STREAM) + if (streamUri != null) { + processFileUpload(streamUri) + } else { + showError("No valid file or URL found in share intent") + } + } + } else { + val streamUri = intent.getParcelableExtra(Intent.EXTRA_STREAM) + if (streamUri != null) { + processFileUpload(streamUri) + } else { + showError("No stream URI found for file share") + } + } + } else if (Intent.ACTION_SEND_MULTIPLE == action) { + val uris = intent.getParcelableArrayListExtra(Intent.EXTRA_STREAM) + if (!uris.isNullOrEmpty()) { + // Upload first file in batch for now + processFileUpload(uris[0]) + } else { + showError("No files found in multi-share intent") + } + } else { + showError("Unsupported share action: $action") + } + } + + private fun String?.isNull_or_blank_url(): Boolean { + if (this.isNullOrBlank()) return true + val trimmed = this.trim() + return !trimmed.startsWith("http://") && !trimmed.startsWith("https://") + } + + private fun extractUrl(text: String): String { + val trimmed = text.trim() + val parts = trimmed.split("\\s+".toRegex()) + for (part in parts) { + if (part.startsWith("http://") || part.startsWith("https://")) { + return part + } + } + return trimmed + } + + private fun processFileUpload(uri: Uri) { + binding.tvStatus.text = "Uploading file..." + binding.progressBar.visibility = View.VISIBLE + binding.progressBar.isIndeterminate = false + binding.progressBar.progress = 0 + + CoroutineScope(Dispatchers.Main).launch { + val result = uploader.uploadFile( + contentResolver = contentResolver, + uri = uri, + onProgress = { pct -> + runOnUiThread { + binding.progressBar.progress = pct + binding.tvPercent.text = "$pct%" + } + } + ) + + result.fold( + onSuccess = { url -> showSuccess(url) }, + onFailure = { err -> showError(err.message ?: "Upload failed") } + ) + } + } + + private fun processUrlUpload(url: String) { + binding.tvStatus.text = "Uploading URL..." + binding.progressBar.visibility = View.VISIBLE + binding.progressBar.isIndeterminate = true + binding.tvPercent.text = "" + + CoroutineScope(Dispatchers.Main).launch { + val result = uploader.uploadUrl(url) + result.fold( + onSuccess = { postUrl -> showSuccess(postUrl) }, + onFailure = { err -> showError(err.message ?: "URL Upload failed") } + ) + } + } + + private fun showSuccess(url: String) { + uploadedUrl = url + copyToClipboard(url) + + binding.progressBar.visibility = View.GONE + binding.tvPercent.text = "100%" + binding.tvStatus.text = "Upload complete! Link copied to clipboard." + binding.tvResultUrl.visibility = View.VISIBLE + binding.tvResultUrl.text = url + binding.layoutButtons.visibility = View.VISIBLE + + Toast.makeText(this, "Uploaded! Link copied to clipboard.", Toast.LENGTH_LONG).show() + } + + private fun showError(msg: String) { + binding.progressBar.visibility = View.GONE + binding.tvStatus.text = "Error: $msg" + binding.btnClose.visibility = View.VISIBLE + } + + private fun copyToClipboard(text: String) { + val clipboard = getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager + val clip = ClipData.newPlainText("fuggloader link", text) + clipboard.setPrimaryClip(clip) + } +} diff --git a/android/app/src/main/java/com/fuggloader/app/UploaderService.kt b/android/app/src/main/java/com/fuggloader/app/UploaderService.kt new file mode 100644 index 0000000..5930e8f --- /dev/null +++ b/android/app/src/main/java/com/fuggloader/app/UploaderService.kt @@ -0,0 +1,223 @@ +package com.fuggloader.app + +import android.content.ContentResolver +import android.net.Uri +import android.provider.OpenableColumns +import android.util.Log +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import okhttp3.MediaType.Companion.toMediaTypeOrNull +import okhttp3.MultipartBody +import okhttp3.OkHttpClient +import okhttp3.Request +import okhttp3.RequestBody +import okhttp3.RequestBody.Companion.toRequestBody +import okio.BufferedSink +import okio.source +import org.json.JSONObject +import java.io.InputStream +import java.util.concurrent.TimeUnit + +class UploaderService(private val prefs: PreferencesManager) { + + private val client = OkHttpClient.Builder() + .connectTimeout(30, TimeUnit.SECONDS) + .writeTimeout(30, TimeUnit.MINUTES) + .readTimeout(30, TimeUnit.MINUTES) + .build() + + suspend fun uploadFile( + contentResolver: ContentResolver, + uri: Uri, + rating: String = prefs.defaultRating, + tags: String = prefs.defaultTags, + visibility: String = prefs.defaultVisibility, + onProgress: (percent: Int) -> Unit + ): Result = withContext(Dispatchers.IO) { + Log.d("UploaderService", "Starting uploadFile for $uri") + try { + val fileName = getFileName(contentResolver, uri) ?: "upload_${System.currentTimeMillis()}" + val mimeType = contentResolver.getType(uri) ?: "application/octet-stream" + val fileSize = getFileSize(contentResolver, uri) + + val inputStream = contentResolver.openInputStream(uri) + ?: return@withContext Result.failure(Exception("Cannot open file stream")) + + val requestBody = object : RequestBody() { + override fun contentType() = mimeType.toMediaTypeOrNull() + + override fun contentLength() = fileSize + + override fun writeTo(sink: BufferedSink) { + val source = inputStream.source() + val buffer = okio.Buffer() + var totalBytesRead = 0L + var readCount: Long + + while (source.read(buffer, 8192L).also { readCount = it } != -1L) { + sink.write(buffer, readCount) + totalBytesRead += readCount + if (fileSize > 0) { + val percent = ((totalBytesRead.toDouble() / fileSize.toDouble()) * 100).toInt() + onProgress(percent.coerceIn(0, 99)) + } + } + } + } + + val multipartBuilder = MultipartBody.Builder() + .setType(MultipartBody.FORM) + .addFormDataPart("file", fileName, requestBody) + + if (rating != "none") { + multipartBuilder.addFormDataPart("rating", rating) + } + + multipartBuilder.addFormDataPart("tags", tags) + .addFormDataPart("visibility", visibility) + + val request = Request.Builder() + .url(prefs.apiUrl) + .addHeader("X-Api-Key", prefs.apiKey) + .addHeader("User-Agent", "fuggloader-android/1.0") + .post(multipartBuilder.build()) + .build() + + val response = client.newCall(request).execute() + val responseBody = response.body?.string() ?: "" + + if (response.isSuccessful) { + onProgress(100) + val postUrl = parsePostUrl(responseBody) + Result.success(postUrl) + } else { + Log.e("UploaderService", "Upload failed: HTTP ${response.code}, Body: $responseBody") + Result.failure(Exception("HTTP ${response.code}: $responseBody")) + } + } catch (e: Exception) { + Result.failure(e) + } + } + + suspend fun uploadUrl( + url: String, + rating: String = prefs.defaultRating, + tags: String = prefs.defaultTags, + visibility: String = prefs.defaultVisibility + ): Result = withContext(Dispatchers.IO) { + Log.d("UploaderService", "Starting uploadUrl for $url") + try { + val json = JSONObject().apply { + put("url", url) + if (rating != "none") { + put("rating", rating) + } + put("tags", tags) + put("visibility", visibility) + } + + val requestBody = RequestBody.create( + "application/json; charset=utf-8".toMediaTypeOrNull(), + json.toString() + ) + + val request = Request.Builder() + .url(prefs.apiUrl) + .addHeader("X-Api-Key", prefs.apiKey) + .addHeader("User-Agent", "fuggloader-android/1.0") + .post(requestBody) + .build() + + val response = client.newCall(request).execute() + val responseBody = response.body?.string() ?: "" + + if (response.isSuccessful) { + val postUrl = parsePostUrl(responseBody) + Result.success(postUrl) + } else { + Log.e("UploaderService", "URL Upload failed: HTTP ${response.code}, Body: $responseBody") + Result.failure(Exception("HTTP ${response.code}: $responseBody")) + } + } catch (e: Exception) { + Result.failure(e) + } + } + + suspend fun testConnection(): Result = withContext(Dispatchers.IO) { + Log.d("UploaderService", "Starting testConnection ping") + try { + val request = Request.Builder() + .url(prefs.apiUrl) + .addHeader("X-Api-Key", prefs.apiKey) + .addHeader("User-Agent", "fuggloader-android/1.0") + .post(ByteArray(0).toRequestBody(null)) + .build() + + val response = client.newCall(request).execute() + val responseBody = response.body?.string() ?: "" + + when (response.code) { + 200 -> Result.success("Success!") + 401 -> Result.failure(Exception("Unauthorized: Invalid API Key")) + 400, 422 -> Result.success("Success! (Auth verified)") + else -> Result.failure(Exception("HTTP ${response.code}: $responseBody")) + } + } catch (e: Exception) { + Result.failure(e) + } + } + + private fun parsePostUrl(jsonResponse: String): String { + return try { + val json = JSONObject(jsonResponse) + if (json.has("url")) { + json.getString("url") + } else if (json.has("post_url")) { + json.getString("post_url") + } else if (json.has("link")) { + json.getString("link") + } else { + jsonResponse + } + } catch (e: Exception) { + jsonResponse + } + } + + private fun getFileName(contentResolver: ContentResolver, uri: Uri): String? { + var result: String? = null + if (uri.scheme == "content") { + contentResolver.query(uri, null, null, null, null)?.use { cursor -> + if (cursor.moveToFirst()) { + val index = cursor.getColumnIndex(OpenableColumns.DISPLAY_NAME) + if (index != -1) { + result = cursor.getString(index) + } + } + } + } + if (result == null) { + result = uri.path + val cut = result?.lastIndexOf('/') + if (cut != null && cut != -1) { + result = result?.substring(cut + 1) + } + } + return result + } + + private fun getFileSize(contentResolver: ContentResolver, uri: Uri): Long { + var size: Long = -1 + if (uri.scheme == "content") { + contentResolver.query(uri, null, null, null, null)?.use { cursor -> + if (cursor.moveToFirst()) { + val index = cursor.getColumnIndex(OpenableColumns.SIZE) + if (index != -1) { + size = cursor.getLong(index) + } + } + } + } + return size + } +} diff --git a/android/app/src/main/res/layout/activity_main.xml b/android/app/src/main/res/layout/activity_main.xml new file mode 100644 index 0000000..22f9fb5 --- /dev/null +++ b/android/app/src/main/res/layout/activity_main.xml @@ -0,0 +1,157 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +