add android app

This commit is contained in:
2026-08-12 22:09:29 +02:00
parent 43e95c9d3e
commit 7e723ae821
30 changed files with 1872 additions and 45 deletions

View File

@@ -16,7 +16,9 @@ A modern, high-contrast **KDE Plasma System Tray Uploader** and desktop integrat
- **Images**: Fast native scaling via PySide6. - **Images**: Fast native scaling via PySide6.
- **Videos** (`.mp4`, `.webm`, etc.): Automatic frame extraction at `00:00:01` via `ffmpeg`. - **Videos** (`.mp4`, `.webm`, etc.): Automatic frame extraction at `00:00:01` via `ffmpeg`.
- **Other Files**: Sleek placeholder file badges showing the file extension. - **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. - **Single Click**: Triggers region screenshot capture & upload.
- **Right Click**: Opens context menu for: - **Right Click**: Opens context menu for:
- **Instance: <Active Instance Name>** (Submenu to switch active f0ckm instance)
- **Capture Region & Upload** - **Capture Region & Upload**
- **Upload File...** - **Upload File...**
- **Upload URL...** - **Upload URL...**
- **Upload from Clipboard** - **Upload from Clipboard**
- **Settings...** - **Settings...**
- **Recent Uploads Gallery**
- **Quit** - **Quit**
- **Double Click**: Opens the Settings dialog. - **Double Click**: Opens the Settings dialog.

View File

@@ -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.

38
android/.gitignore vendored Normal file
View File

@@ -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/

66
android/README.md Normal file
View File

@@ -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.

1
android/app/.gitignore vendored Normal file
View File

@@ -0,0 +1 @@
/build/

View File

@@ -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")
}

View File

@@ -0,0 +1,63 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" android:maxSdkVersion="32" />
<uses-permission android:name="android.permission.READ_MEDIA_IMAGES" />
<uses-permission android:name="android.permission.READ_MEDIA_VIDEO" />
<uses-permission android:name="android.permission.READ_MEDIA_AUDIO" />
<application
android:allowBackup="true"
android:icon="@mipmap/ic_launcher"
android:roundIcon="@mipmap/ic_launcher"
android:label="@string/app_name"
android:supportsRtl="true"
android:theme="@style/Theme.FuggloaderApp"
android:usesCleartextTraffic="true">
<!-- Main Configuration Activity -->
<activity
android:name=".MainActivity"
android:exported="true"
android:label="@string/app_name"
android:theme="@style/Theme.FuggloaderApp">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
<!-- Share Target Activity (Receives Android Share Intent) -->
<activity
android:name=".ShareActivity"
android:exported="true"
android:label="Upload to fuggloader"
android:theme="@style/Theme.FuggloaderApp.Translucent">
<!-- Single File / URL Share Intent -->
<intent-filter>
<action android:name="android.intent.action.SEND" />
<category android:name="android.intent.category.DEFAULT" />
<data android:mimeType="*/*" />
</intent-filter>
<!-- Text / URL Share Intent -->
<intent-filter>
<action android:name="android.intent.action.SEND" />
<category android:name="android.intent.category.DEFAULT" />
<data android:mimeType="text/plain" />
</intent-filter>
<!-- Multiple Files Share Intent -->
<intent-filter>
<action android:name="android.intent.action.SEND_MULTIPLE" />
<category android:name="android.intent.category.DEFAULT" />
<data android:mimeType="*/*" />
</intent-filter>
</activity>
</application>
</manifest>

View File

@@ -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()
}
)
}
}
}

View File

@@ -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()
}
}

View File

@@ -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<Uri>(Intent.EXTRA_STREAM)
if (streamUri != null) {
processFileUpload(streamUri)
} else {
showError("No valid file or URL found in share intent")
}
}
} else {
val streamUri = intent.getParcelableExtra<Uri>(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<Uri>(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)
}
}

View File

@@ -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<String> = 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<String> = 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<String> = 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
}
}

View File

@@ -0,0 +1,157 @@
<?xml version="1.0" encoding="utf-8"?>
<ScrollView xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:background="#121216"
android:fillViewport="true"
android:padding="20dp">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical">
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="fuggloader"
android:textColor="#FFFFFF"
android:textSize="22sp"
android:textStyle="bold" />
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="4dp"
android:text="Configure your server credentials to enable Android Share menu uploads."
android:textColor="#A0A0B0"
android:textSize="14sp" />
<!-- API Endpoint URL -->
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="24dp"
android:text="API Endpoint URL"
android:textColor="#E0E0E0"
android:textStyle="bold" />
<EditText
android:id="@+id/etApiUrl"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="6dp"
android:background="#1E1E24"
android:hint="https://your-fuggloader-site.com/api/v2/upload"
android:inputType="textUri"
android:padding="12dp"
android:textColor="#FFFFFF"
android:textColorHint="#606070" />
<!-- API Key -->
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="16dp"
android:text="API Key"
android:textColor="#E0E0E0"
android:textStyle="bold" />
<EditText
android:id="@+id/etApiKey"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="6dp"
android:background="#1E1E24"
android:hint="Paste your API key here"
android:inputType="textPassword"
android:padding="12dp"
android:textColor="#FFFFFF"
android:textColorHint="#606070" />
<!-- Default Tags -->
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="16dp"
android:text="Default Tags (comma separated)"
android:textColor="#E0E0E0"
android:textStyle="bold" />
<EditText
android:id="@+id/etTags"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="6dp"
android:background="#1E1E24"
android:hint="android, mobile"
android:inputType="text"
android:padding="12dp"
android:textColor="#FFFFFF"
android:textColorHint="#606070" />
<!-- Default Rating -->
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="16dp"
android:text="Default Rating"
android:textColor="#E0E0E0"
android:textStyle="bold" />
<Spinner
android:id="@+id/spinnerRating"
android:layout_width="match_parent"
android:layout_height="48dp"
android:layout_marginTop="6dp"
android:background="#1E1E24"
android:padding="8dp" />
<!-- Default Visibility -->
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="16dp"
android:text="Default Visibility"
android:textColor="#E0E0E0"
android:textStyle="bold" />
<Spinner
android:id="@+id/spinnerVisibility"
android:layout_width="match_parent"
android:layout_height="48dp"
android:layout_marginTop="6dp"
android:background="#1E1E24"
android:padding="8dp" />
<!-- Buttons -->
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="30dp"
android:orientation="horizontal">
<Button
android:id="@+id/btnTestConnection"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginEnd="8dp"
android:layout_weight="1"
android:backgroundTint="#2E2E3A"
android:text="Test"
android:textColor="#FFFFFF" />
<Button
android:id="@+id/btnSave"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginStart="8dp"
android:layout_weight="1"
android:backgroundTint="#6200EE"
android:text="Save Settings"
android:textColor="#FFFFFF" />
</LinearLayout>
</LinearLayout>
</ScrollView>

View File

@@ -0,0 +1,101 @@
<?xml version="1.0" encoding="utf-8"?>
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:background="#90000000"
android:padding="24dp">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_gravity="center"
android:background="#1E1E24"
android:orientation="vertical"
android:padding="20dp">
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="fuggloader Upload"
android:textColor="#FFFFFF"
android:textSize="18sp"
android:textStyle="bold" />
<TextView
android:id="@+id/tvStatus"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:text="Preparing upload..."
android:textColor="#B0B0C0"
android:textSize="14sp" />
<ProgressBar
android:id="@+id/progressBar"
style="?android:attr/progressBarStyleHorizontal"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="16dp"
android:max="100" />
<TextView
android:id="@+id/tvPercent"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="end"
android:layout_marginTop="4dp"
android:text="0%"
android:textColor="#808090"
android:textSize="12sp" />
<TextView
android:id="@+id/tvResultUrl"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="12dp"
android:background="#121216"
android:padding="8dp"
android:textColor="#A5D6A7"
android:textSize="13sp"
android:visibility="gone" />
<LinearLayout
android:id="@+id/layoutButtons"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="16dp"
android:orientation="horizontal"
android:visibility="gone">
<Button
android:id="@+id/btnCopy"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginEnd="4dp"
android:layout_weight="1"
android:backgroundTint="#2E2E3A"
android:text="Copy Link" />
<Button
android:id="@+id/btnOpen"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginStart="4dp"
android:layout_weight="1"
android:backgroundTint="#6200EE"
android:text="Open Post" />
</LinearLayout>
<Button
android:id="@+id/btnClose"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="end"
android:layout_marginTop="12dp"
android:backgroundTint="#333333"
android:text="Close"
android:textColor="#CCCCCC"
android:visibility="gone" />
</LinearLayout>
</FrameLayout>

View File

@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@color/ic_launcher_background" />
<foreground android:drawable="@mipmap/ic_launcher_foreground" />
</adaptive-icon>

Binary file not shown.

After

Width:  |  Height:  |  Size: 85 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 85 KiB

View File

@@ -0,0 +1,10 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<color name="purple_500">#6200EE</color>
<color name="purple_700">#3700B3</color>
<color name="teal_200">#03DAC5</color>
<color name="teal_700">#018786</color>
<color name="black">#FF000000</color>
<color name="white">#FFFFFFFF</color>
<color name="ic_launcher_background">#FFFFFF</color>
</resources>

View File

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

View File

@@ -0,0 +1,19 @@
<resources xmlns:tools="http://schemas.android.com/tools">
<!-- Base application theme -->
<style name="Theme.FuggloaderApp" parent="Theme.MaterialComponents.DayNight.NoActionBar">
<item name="colorPrimary">@color/purple_500</item>
<item name="colorPrimaryVariant">@color/purple_700</item>
<item name="colorOnPrimary">@color/white</item>
<item name="colorSecondary">@color/teal_200</item>
<item name="colorSecondaryVariant">@color/teal_700</item>
<item name="colorOnSecondary">@color/black</item>
<item name="android:statusBarColor">#121216</item>
</style>
<!-- Translucent Overlay Theme for Share Activity -->
<style name="Theme.FuggloaderApp.Translucent" parent="Theme.FuggloaderApp">
<item name="android:windowBackground">@android:color/transparent</item>
<item name="android:windowIsTranslucent">true</item>
<item name="android:windowAnimationStyle">@android:style/Animation.Dialog</item>
</style>
</resources>

BIN
android/appicon2.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 85 KiB

4
android/build.gradle.kts Normal file
View File

@@ -0,0 +1,4 @@
plugins {
id("com.android.application") version "8.8.0" apply false
id("org.jetbrains.kotlin.android") version "2.0.21" apply false
}

View File

@@ -0,0 +1,7 @@
# Enable AndroidX support
android.useAndroidX=true
# Automatically convert third-party libraries to use AndroidX
android.enableJetifier=false
# Optimize R class generation
android.nonTransitiveRClass=true

Binary file not shown.

View File

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

248
android/gradlew vendored Executable file
View File

@@ -0,0 +1,248 @@
#!/bin/sh
#
# Copyright © 2015 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.
#
# SPDX-License-Identifier: Apache-2.0
#
##############################################################################
#
# 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/platforms/jvm/plugins-application/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 -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || 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
# 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" )
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, 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" \
-jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \
"$@"
# 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" "$@"

93
android/gradlew.bat vendored Normal file
View File

@@ -0,0 +1,93 @@
@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
@rem SPDX-License-Identifier: Apache-2.0
@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
@rem Execute Gradle
"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %*
: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

View File

@@ -0,0 +1,48 @@
{
"categories": [
{
"id": "fuggloader_category",
"name": "fuggloader",
"shortcuts": [
{
"id": "upload_file_to_fuggloader",
"name": "Upload File to fuggloader",
"description": "Upload shared file to fuggloader server",
"executionType": "APP",
"url": "https://YOUR_SITE.com/api/v2/upload",
"method": "POST",
"bodyType": "FORM_DATA",
"parameters": [
{
"key": "file",
"value": "file",
"fileName": "upload.png"
}
],
"headers": [
{
"key": "X-Api-Key",
"value": "YOUR_API_KEY"
}
]
},
{
"id": "upload_url_to_fuggloader",
"name": "Upload URL to fuggloader",
"description": "Upload shared URL to fuggloader server",
"executionType": "APP",
"url": "https://YOUR_SITE.com/api/v2/upload",
"method": "POST",
"bodyType": "JSON",
"bodyContent": "{\"url\": \"{url}\"}",
"headers": [
{
"key": "X-Api-Key",
"value": "YOUR_API_KEY"
}
]
}
]
}
]
}

22
android/icon_app.svg Normal file
View File

@@ -0,0 +1,22 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 128 128" width="128" height="128">
<!-- Solid White Background Badge -->
<rect x="2" y="2" width="124" height="124" rx="28" fill="#ffffff" />
<!-- Sharp Black Sun Graphic -->
<g fill="none" stroke="#000000" stroke-linecap="square" stroke-linejoin="miter">
<circle cx="64" cy="64" r="52" fill="none" stroke="#000000" stroke-width="5" />
<path d="M 116.00 64.00 L 98.00 64.00 L 96.84 72.80 L 79.45 68.14" stroke="#000000" stroke-width="4.5" stroke-linecap="square" stroke-linejoin="miter" fill="none" />
<path d="M 109.03 90.00 L 93.44 81.00 L 88.04 88.04 L 75.31 75.31" stroke="#000000" stroke-width="4.5" stroke-linecap="square" stroke-linejoin="miter" fill="none" />
<path d="M 90.00 109.03 L 81.00 93.44 L 72.80 96.84 L 68.14 79.45" stroke="#000000" stroke-width="4.5" stroke-linecap="square" stroke-linejoin="miter" fill="none" />
<path d="M 64.00 116.00 L 64.00 98.00 L 55.20 96.84 L 59.86 79.45" stroke="#000000" stroke-width="4.5" stroke-linecap="square" stroke-linejoin="miter" fill="none" />
<path d="M 38.00 109.03 L 47.00 93.44 L 39.96 88.04 L 52.69 75.31" stroke="#000000" stroke-width="4.5" stroke-linecap="square" stroke-linejoin="miter" fill="none" />
<path d="M 18.97 90.00 L 34.56 81.00 L 31.16 72.80 L 48.55 68.14" stroke="#000000" stroke-width="4.5" stroke-linecap="square" stroke-linejoin="miter" fill="none" />
<path d="M 12.00 64.00 L 30.00 64.00 L 31.16 55.20 L 48.55 59.86" stroke="#000000" stroke-width="4.5" stroke-linecap="square" stroke-linejoin="miter" fill="none" />
<path d="M 18.97 38.00 L 34.56 47.00 L 39.96 39.96 L 52.69 52.69" stroke="#000000" stroke-width="4.5" stroke-linecap="square" stroke-linejoin="miter" fill="none" />
<path d="M 38.00 18.97 L 47.00 34.56 L 55.20 31.16 L 59.86 48.55" stroke="#000000" stroke-width="4.5" stroke-linecap="square" stroke-linejoin="miter" fill="none" />
<path d="M 64.00 12.00 L 64.00 30.00 L 72.80 31.16 L 68.14 48.55" stroke="#000000" stroke-width="4.5" stroke-linecap="square" stroke-linejoin="miter" fill="none" />
<path d="M 90.00 18.97 L 81.00 34.56 L 88.04 39.96 L 75.31 52.69" stroke="#000000" stroke-width="4.5" stroke-linecap="square" stroke-linejoin="miter" fill="none" />
<path d="M 109.03 38.00 L 93.44 47.00 L 96.84 55.20 L 79.45 59.86" stroke="#000000" stroke-width="4.5" stroke-linecap="square" stroke-linejoin="miter" fill="none" />
<circle cx="64" cy="64" r="16" fill="none" stroke="#000000" stroke-width="5" />
</g>
</svg>

After

Width:  |  Height:  |  Size: 2.5 KiB

View File

@@ -0,0 +1,17 @@
pluginManagement {
repositories {
google()
mavenCentral()
gradlePluginPortal()
}
}
dependencyResolutionManagement {
repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
repositories {
google()
mavenCentral()
}
}
rootProject.name = "fuggloader"
include(":app")

370
gui.py
View File

@@ -14,6 +14,7 @@ import shutil
import socket import socket
import ssl import ssl
import threading import threading
import signal
from http.server import HTTPServer, BaseHTTPRequestHandler from http.server import HTTPServer, BaseHTTPRequestHandler
from PySide6.QtCore import Qt, QThread, QObject, Signal, QUrl, QFile, QIODevice, QTimer, QMimeData, QProcess from PySide6.QtCore import Qt, QThread, QObject, Signal, QUrl, QFile, QIODevice, QTimer, QMimeData, QProcess
@@ -37,6 +38,25 @@ def normalize_file_path(path_str: str) -> str:
path_str = urllib.parse.unquote(path_str[7:]) path_str = urllib.parse.unquote(path_str[7:])
return os.path.abspath(path_str) return os.path.abspath(path_str)
def normalize_api_url(url: str) -> str:
if not url:
return ""
url = url.strip().strip("'\"")
if not url:
return ""
if not (url.startswith("http://") or url.startswith("https://")):
url = "http://" + url
parsed = urllib.parse.urlparse(url)
path = parsed.path.rstrip('/')
if not path or path == "":
return f"{url.rstrip('/')}/api/v2/upload"
elif path.endswith("/api/v2"):
return f"{url.rstrip('/')}/upload"
elif not path.endswith("/upload"):
return f"{url.rstrip('/')}/api/v2/upload"
return url
def parse_upload_target(arg: str) -> tuple[str, bool]: def parse_upload_target(arg: str) -> tuple[str, bool]:
if not arg: if not arg:
return ("", False) return ("", False)
@@ -206,6 +226,14 @@ def add_history_entry(entry):
save_history(history) save_history(history)
DEFAULT_CONFIG = { DEFAULT_CONFIG = {
"instances": [
{
"name": "Default Instance",
"api_url": "",
"api_key": ""
}
],
"active_instance_index": 0,
"api_url": "", "api_url": "",
"api_key": "", "api_key": "",
"default_rating": "", "default_rating": "",
@@ -227,6 +255,27 @@ def load_config():
data = json.load(f) data = json.load(f)
config = DEFAULT_CONFIG.copy() config = DEFAULT_CONFIG.copy()
config.update(data) config.update(data)
instances = config.get("instances")
if not isinstance(instances, list) or not instances:
url = data.get("api_url") or config.get("api_url", "")
key = data.get("api_key") or config.get("api_key", "")
instances = [{"name": "Default Instance", "api_url": normalize_api_url(url), "api_key": key}]
config["instances"] = instances
config["active_instance_index"] = 0
else:
for inst in instances:
if "api_url" in inst:
inst["api_url"] = normalize_api_url(inst["api_url"])
idx = config.get("active_instance_index", 0)
if not isinstance(idx, int) or idx < 0 or idx >= len(config["instances"]):
idx = 0
config["active_instance_index"] = 0
active = config["instances"][idx]
config["api_url"] = normalize_api_url(active.get("api_url", ""))
config["api_key"] = active.get("api_key", "")
return config return config
except Exception: except Exception:
return DEFAULT_CONFIG.copy() return DEFAULT_CONFIG.copy()
@@ -241,6 +290,8 @@ def get_env_config():
os.path.join(os.getcwd(), ".env") os.path.join(os.getcwd(), ".env")
] ]
dotenv_path = None dotenv_path = None
env_url = ""
env_key = ""
for path in candidates: for path in candidates:
if os.path.exists(path): if os.path.exists(path):
dotenv_path = path dotenv_path = path
@@ -256,9 +307,11 @@ def get_env_config():
key = k.strip() key = k.strip()
val = v.strip().strip("'\"") val = v.strip().strip("'\"")
if key == "F0CKM_URL" and val: if key == "F0CKM_URL" and val:
url = val if val.endswith("/api/v2/upload") else f"{val.rstrip('/')}/api/v2/upload" url = normalize_api_url(val)
env_url = url
config["api_url"] = url config["api_url"] = url
elif key == "API_KEY" and val: elif key == "API_KEY" and val:
env_key = val
config["api_key"] = val config["api_key"] = val
elif key == "RATING": elif key == "RATING":
config["default_rating"] = val config["default_rating"] = val
@@ -277,11 +330,37 @@ def get_env_config():
for k, v in user_cfg.items(): for k, v in user_cfg.items():
config[k] = v config[k] = v
instances = config.get("instances")
if not isinstance(instances, list) or not instances:
url = config.get("api_url") or env_url
key = config.get("api_key") or env_key
instances = [{"name": "Default Instance", "api_url": normalize_api_url(url), "api_key": key}]
config["instances"] = instances
config["active_instance_index"] = 0
idx = config.get("active_instance_index", 0)
if not isinstance(idx, int) or idx < 0 or idx >= len(instances):
idx = 0
config["active_instance_index"] = 0
active_inst = instances[idx]
config["api_url"] = normalize_api_url(active_inst.get("api_url", ""))
config["api_key"] = active_inst.get("api_key", "")
config["active_instance_name"] = active_inst.get("name", "Default Instance")
return config return config
def save_config(config): def save_config(config):
try: try:
os.makedirs(CONFIG_DIR, exist_ok=True) os.makedirs(CONFIG_DIR, exist_ok=True)
if "instances" in config and isinstance(config["instances"], list) and config["instances"]:
for inst in config["instances"]:
if "api_url" in inst:
inst["api_url"] = normalize_api_url(inst["api_url"])
idx = config.get("active_instance_index", 0)
if 0 <= idx < len(config["instances"]):
config["api_url"] = normalize_api_url(config["instances"][idx].get("api_url", ""))
config["api_key"] = config["instances"][idx].get("api_key", "")
with open(CONFIG_PATH, 'w') as f: with open(CONFIG_PATH, 'w') as f:
json.dump(config, f, indent=4) json.dump(config, f, indent=4)
return True return True
@@ -334,7 +413,7 @@ class ConnectionTester(QObject):
def __init__(self, url, api_key): def __init__(self, url, api_key):
super().__init__() super().__init__()
self.url = url self.url = normalize_api_url(url)
self.api_key = api_key self.api_key = api_key
def run(self): def run(self):
@@ -389,7 +468,7 @@ class SettingsDialog(QDialog):
super().__init__(parent) super().__init__(parent)
self.tray_app = tray_app self.tray_app = tray_app
self.setWindowTitle("f0ckm Uploader Settings") self.setWindowTitle("f0ckm Uploader Settings")
self.resize(540, 680) self.resize(560, 700)
app_icon = get_app_icon() app_icon = get_app_icon()
if not app_icon.isNull(): if not app_icon.isNull():
@@ -397,6 +476,10 @@ class SettingsDialog(QDialog):
self.tester_thread = None self.tester_thread = None
self.tester = None self.tester = None
self.instances = []
self.current_instance_index = 0
self._ignore_instance_signals = False
self.init_ui() self.init_ui()
@@ -414,7 +497,7 @@ class SettingsDialog(QDialog):
header_text_layout = QVBoxLayout() header_text_layout = QVBoxLayout()
header_title = QLabel("f0ckm Uploader") header_title = QLabel("f0ckm Uploader")
header_title.setStyleSheet("font-size: 16px; font-weight: bold;") header_title.setStyleSheet("font-size: 16px; font-weight: bold;")
header_subtitle = QLabel("Configure connection settings and default options") header_subtitle = QLabel("Configure f0ckm instances, connection settings and default options")
header_subtitle.setStyleSheet("font-size: 11px; opacity: 0.7;") header_subtitle.setStyleSheet("font-size: 11px; opacity: 0.7;")
header_text_layout.addWidget(header_title) header_text_layout.addWidget(header_title)
header_text_layout.addWidget(header_subtitle) header_text_layout.addWidget(header_subtitle)
@@ -424,20 +507,45 @@ class SettingsDialog(QDialog):
header_layout.addStretch() header_layout.addStretch()
main_layout.addLayout(header_layout) main_layout.addLayout(header_layout)
# Section 1: API Connection Settings # Section 1: API Connection & Instances Settings
grp_api = QGroupBox("API Connection") grp_api = QGroupBox("f0ckm Instances & Connection")
api_layout = QFormLayout(grp_api) api_layout = QFormLayout(grp_api)
api_layout.setContentsMargins(15, 20, 15, 15) api_layout.setContentsMargins(15, 20, 15, 15)
api_layout.setSpacing(10) api_layout.setSpacing(10)
# Instance Selection & Management Row
inst_row_layout = QHBoxLayout()
self.cb_instance = QComboBox()
self.cb_instance.currentIndexChanged.connect(self.on_instance_changed)
inst_row_layout.addWidget(self.cb_instance, stretch=1)
self.btn_add_instance = QPushButton("+ Add")
self.btn_add_instance.setToolTip("Add a new f0ckm instance")
self.btn_add_instance.clicked.connect(self.on_add_instance)
inst_row_layout.addWidget(self.btn_add_instance)
self.btn_rename_instance = QPushButton("Rename")
self.btn_rename_instance.setToolTip("Rename currently selected instance")
self.btn_rename_instance.clicked.connect(self.on_rename_instance)
inst_row_layout.addWidget(self.btn_rename_instance)
self.btn_delete_instance = QPushButton("Delete")
self.btn_delete_instance.setToolTip("Delete currently selected instance")
self.btn_delete_instance.clicked.connect(self.on_delete_instance)
inst_row_layout.addWidget(self.btn_delete_instance)
api_layout.addRow("Instance:", inst_row_layout)
self.txt_url = QLineEdit() self.txt_url = QLineEdit()
self.txt_url.setPlaceholderText("https://example.com/api/v2/upload") self.txt_url.setPlaceholderText("https://example.com/api/v2/upload")
self.txt_url.textChanged.connect(self.on_url_or_key_edited)
api_layout.addRow("API URL:", self.txt_url) api_layout.addRow("API URL:", self.txt_url)
key_layout = QHBoxLayout() key_layout = QHBoxLayout()
self.txt_key = QLineEdit() self.txt_key = QLineEdit()
self.txt_key.setEchoMode(QLineEdit.Password) self.txt_key.setEchoMode(QLineEdit.Password)
self.txt_key.setPlaceholderText("your_api_key_here") self.txt_key.setPlaceholderText("your_api_key_here")
self.txt_key.textChanged.connect(self.on_url_or_key_edited)
key_layout.addWidget(self.txt_key) key_layout.addWidget(self.txt_key)
self.btn_toggle_key = QPushButton("Show") self.btn_toggle_key = QPushButton("Show")
@@ -578,11 +686,99 @@ class SettingsDialog(QDialog):
else: else:
self.txt_key.setEchoMode(QLineEdit.Password) self.txt_key.setEchoMode(QLineEdit.Password)
self.btn_toggle_key.setText("Show") self.btn_toggle_key.setText("Show")
def refresh_instance_combo(self):
self._ignore_instance_signals = True
self.cb_instance.clear()
for inst in self.instances:
self.cb_instance.addItem(inst.get("name", "Unnamed Instance"))
if 0 <= self.current_instance_index < len(self.instances):
self.cb_instance.setCurrentIndex(self.current_instance_index)
self._ignore_instance_signals = False
self.load_instance_fields(self.current_instance_index)
def load_instance_fields(self, index):
if 0 <= index < len(self.instances):
inst = self.instances[index]
self.txt_url.setText(inst.get("api_url", ""))
self.txt_key.setText(inst.get("api_key", ""))
def on_instance_changed(self, new_index):
if self._ignore_instance_signals or new_index < 0 or new_index >= len(self.instances):
return
if 0 <= self.current_instance_index < len(self.instances):
self.instances[self.current_instance_index]["api_url"] = self.txt_url.text().strip()
self.instances[self.current_instance_index]["api_key"] = self.txt_key.text().strip()
self.current_instance_index = new_index
self.load_instance_fields(new_index)
def on_url_or_key_edited(self):
if self._ignore_instance_signals:
return
if 0 <= self.current_instance_index < len(self.instances):
self.instances[self.current_instance_index]["api_url"] = self.txt_url.text().strip()
self.instances[self.current_instance_index]["api_key"] = self.txt_key.text().strip()
def on_add_instance(self):
name, ok = QInputDialog.getText(self, "Add Instance", "Enter name for new f0ckm instance:")
if ok and name.strip():
name = name.strip()
self.on_url_or_key_edited()
new_inst = {
"name": name,
"api_url": "",
"api_key": ""
}
self.instances.append(new_inst)
self.current_instance_index = len(self.instances) - 1
self.refresh_instance_combo()
def on_rename_instance(self):
if not (0 <= self.current_instance_index < len(self.instances)):
return
curr_name = self.instances[self.current_instance_index].get("name", "")
name, ok = QInputDialog.getText(self, "Rename Instance", "Enter new instance name:", text=curr_name)
if ok and name.strip():
name = name.strip()
self.instances[self.current_instance_index]["name"] = name
self.refresh_instance_combo()
def on_delete_instance(self):
if len(self.instances) <= 1:
QMessageBox.information(self, "Cannot Delete", "You must keep at least one f0ckm instance.")
return
curr_name = self.instances[self.current_instance_index].get("name", "")
reply = QMessageBox.question(
self,
"Delete Instance",
f"Are you sure you want to delete instance '{curr_name}'?",
QMessageBox.Yes | QMessageBox.No
)
if reply == QMessageBox.Yes:
self.instances.pop(self.current_instance_index)
self.current_instance_index = max(0, self.current_instance_index - 1)
self.refresh_instance_combo()
def load_current_settings(self): def load_current_settings(self):
config = load_config() config = get_env_config()
self.txt_url.setText(config.get("api_url", ""))
self.txt_key.setText(config.get("api_key", "")) self.instances = [dict(inst) for inst in config.get("instances", [])]
if not self.instances:
self.instances = [{
"name": "Default Instance",
"api_url": config.get("api_url", ""),
"api_key": config.get("api_key", "")
}]
self.current_instance_index = config.get("active_instance_index", 0)
if self.current_instance_index < 0 or self.current_instance_index >= len(self.instances):
self.current_instance_index = 0
self.refresh_instance_combo()
rating = config.get("default_rating", "").lower() rating = config.get("default_rating", "").lower()
rating_map = {"": 0, "s": 1, "sfw": 1, "safe": 1, "q": 2, "nsfw": 2, "questionable": 2, "e": 3, "nsfl": 3, "explicit": 3} rating_map = {"": 0, "s": 1, "sfw": 1, "safe": 1, "q": 2, "nsfw": 2, "questionable": 2, "e": 3, "nsfl": 3, "explicit": 3}
@@ -620,7 +816,10 @@ class SettingsDialog(QDialog):
self.btn_test.setEnabled(False) self.btn_test.setEnabled(False)
self.btn_test.setText("Testing...") self.btn_test.setText("Testing...")
url = self.txt_url.text().strip() raw_url = self.txt_url.text().strip()
url = normalize_api_url(raw_url)
if url != raw_url:
self.txt_url.setText(url)
key = self.txt_key.text().strip() key = self.txt_key.text().strip()
self.tester_thread = QThread() self.tester_thread = QThread()
@@ -671,9 +870,19 @@ class SettingsDialog(QDialog):
play_success_sound(sound_path, volume=self.slider_volume.value()) play_success_sound(sound_path, volume=self.slider_volume.value())
def save_settings(self): def save_settings(self):
self.on_url_or_key_edited()
active_idx = self.cb_instance.currentIndex()
if active_idx < 0 or active_idx >= len(self.instances):
active_idx = 0
active_inst = self.instances[active_idx] if self.instances else {"api_url": "", "api_key": ""}
config = { config = {
"api_url": self.txt_url.text().strip(), "instances": self.instances,
"api_key": self.txt_key.text().strip(), "active_instance_index": active_idx,
"api_url": active_inst.get("api_url", ""),
"api_key": active_inst.get("api_key", ""),
"default_rating": ["", "s", "q", "e"][self.cb_rating.currentIndex()], "default_rating": ["", "s", "q", "e"][self.cb_rating.currentIndex()],
"default_visibility": ["0", "1", "2"][self.cb_visibility.currentIndex()], "default_visibility": ["0", "1", "2"][self.cb_visibility.currentIndex()],
"default_tags": self.txt_tags.text().strip(), "default_tags": self.txt_tags.text().strip(),
@@ -687,11 +896,14 @@ class SettingsDialog(QDialog):
"success_audio_path": self.txt_sound_path.text().strip(), "success_audio_path": self.txt_sound_path.text().strip(),
"success_audio_volume": self.slider_volume.value() "success_audio_volume": self.slider_volume.value()
} }
if save_config(config): if save_config(config):
set_autostart(config["autostart"]) set_autostart(config["autostart"])
if self.tray_app and hasattr(self.tray_app, "load_tray_icon"): if self.tray_app:
self.tray_app.load_tray_icon() if hasattr(self.tray_app, "load_tray_icon"):
self.tray_app.load_tray_icon()
if hasattr(self.tray_app, "rebuild_instance_menu"):
self.tray_app.rebuild_instance_menu()
self.accept() self.accept()
else: else:
QMessageBox.critical(self, "Error", "Failed to save configuration file.") QMessageBox.critical(self, "Error", "Failed to save configuration file.")
@@ -1203,7 +1415,7 @@ class URLPostThread(QThread):
}, },
method="POST" method="POST"
) )
with urllib.request.urlopen(req, timeout=30) as resp: with urllib.request.urlopen(req, timeout=300) as resp:
resp_text = resp.read().decode("utf-8") resp_text = resp.read().decode("utf-8")
print(f"[f0ckm-gui] [URL POST OK] Server Response: {resp_text}", flush=True) print(f"[f0ckm-gui] [URL POST OK] Server Response: {resp_text}", flush=True)
self.finished.emit(True, resp_text) self.finished.emit(True, resp_text)
@@ -1232,7 +1444,7 @@ class StreamingDownloadThread(QThread):
"User-Agent": "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36" "User-Agent": "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36"
} }
) )
with urllib.request.urlopen(req, timeout=30) as resp: with urllib.request.urlopen(req, timeout=120) as resp:
total_size = int(resp.headers.get("Content-Length", 0)) total_size = int(resp.headers.get("Content-Length", 0))
content_type = resp.headers.get("Content-Type", "") content_type = resp.headers.get("Content-Type", "")
print(f"[f0ckm-gui] [DOWNLOAD HTTP OK] Content-Type: '{content_type}', Length: {total_size} bytes", flush=True) print(f"[f0ckm-gui] [DOWNLOAD HTTP OK] Content-Type: '{content_type}', Length: {total_size} bytes", flush=True)
@@ -1253,7 +1465,8 @@ class StreamingDownloadThread(QThread):
temp_path = os.path.join(temp_dir, f"f0ckm_remote_{int(time.time())}_{clean_name}") temp_path = os.path.join(temp_dir, f"f0ckm_remote_{int(time.time())}_{clean_name}")
received = 0 received = 0
chunk_size = 32768 chunk_size = 131072
last_emit = 0.0
with open(temp_path, "wb") as f: with open(temp_path, "wb") as f:
while True: while True:
if self.aborted: if self.aborted:
@@ -1265,7 +1478,10 @@ class StreamingDownloadThread(QThread):
break break
f.write(chunk) f.write(chunk)
received += len(chunk) received += len(chunk)
self.progress.emit(received, total_size) now = time.time()
if now - last_emit >= 0.05 or (total_size > 0 and received == total_size):
self.progress.emit(received, total_size)
last_emit = now
print(f"[f0ckm-gui] [DOWNLOAD COMPLETE] Saved remote file to: {temp_path} ({received} bytes)", flush=True) print(f"[f0ckm-gui] [DOWNLOAD COMPLETE] Saved remote file to: {temp_path} ({received} bytes)", flush=True)
self.finished.emit(True, temp_path) self.finished.emit(True, temp_path)
@@ -1320,6 +1536,8 @@ class StreamingUploadThread(QThread):
path += "?" + parsed_url.query path += "?" + parsed_url.query
raw_sock = socket.create_connection((host, port), timeout=30) raw_sock = socket.create_connection((host, port), timeout=30)
raw_sock.settimeout(1800.0)
if parsed_url.scheme == "https": if parsed_url.scheme == "https":
ctx = ssl.create_default_context() ctx = ssl.create_default_context()
self.sock = ctx.wrap_socket(raw_sock, server_hostname=host) self.sock = ctx.wrap_socket(raw_sock, server_hostname=host)
@@ -1343,7 +1561,9 @@ class StreamingUploadThread(QThread):
total_sent += len(encoder.header_bytes) total_sent += len(encoder.header_bytes)
self.progress.emit(total_sent, encoder.total_size) self.progress.emit(total_sent, encoder.total_size)
chunk_size = 32768 chunk_size = 131072
last_progress_time = 0.0
with open(self.file_path, "rb") as f: with open(self.file_path, "rb") as f:
while True: while True:
if self.aborted: if self.aborted:
@@ -1356,10 +1576,12 @@ class StreamingUploadThread(QThread):
self.sock.sendall(chunk) self.sock.sendall(chunk)
total_sent += len(chunk) total_sent += len(chunk)
pct = min(99, int((total_sent / encoder.total_size) * 100)) now = time.time()
bytes_sent_calc = int(encoder.total_size * (pct / 100.0)) if now - last_progress_time >= 0.05 or total_sent >= encoder.total_size:
self.progress.emit(bytes_sent_calc, encoder.total_size) pct = min(99, int((total_sent / encoder.total_size) * 100))
time.sleep(0.005) bytes_sent_calc = int(encoder.total_size * (pct / 100.0))
self.progress.emit(bytes_sent_calc, encoder.total_size)
last_progress_time = now
self.sock.sendall(encoder.footer_bytes) self.sock.sendall(encoder.footer_bytes)
total_sent += len(encoder.footer_bytes) total_sent += len(encoder.footer_bytes)
@@ -1423,7 +1645,10 @@ class StreamingUploadThread(QThread):
if self.aborted: if self.aborted:
self.finished.emit(False, "ABORTED") self.finished.emit(False, "ABORTED")
else: else:
self.finished.emit(False, str(e)) err_msg = str(e)
if isinstance(e, (socket.timeout, TimeoutError)) or "timed out" in err_msg.lower():
err_msg = f"Upload timed out: socket read timed out ({err_msg})"
self.finished.emit(False, err_msg)
finally: finally:
if self.sock: if self.sock:
try: try:
@@ -1449,6 +1674,7 @@ class SystemTrayApp(QObject):
self.upload_queue = [] # List of tuples: (target_str, is_url) self.upload_queue = [] # List of tuples: (target_str, is_url)
self.is_uploading = False self.is_uploading = False
self.active_thread = None self.active_thread = None
self._running_threads = set()
self.current_target = None self.current_target = None
self.total_queue_count = 0 self.total_queue_count = 0
self.current_item_index = 0 self.current_item_index = 0
@@ -1459,6 +1685,11 @@ class SystemTrayApp(QObject):
title_action = QAction("f0ckm Uploader", self.menu) title_action = QAction("f0ckm Uploader", self.menu)
title_action.setEnabled(False) title_action.setEnabled(False)
self.menu.addAction(title_action) self.menu.addAction(title_action)
# Instance Submenu
self.instance_menu = QMenu("Switch Instance", self.menu)
self.menu.addMenu(self.instance_menu)
self.menu.addSeparator() self.menu.addSeparator()
# Actions # Actions
@@ -1505,6 +1736,59 @@ class SystemTrayApp(QObject):
self.settings_dialog = None self.settings_dialog = None
self.gallery_window = None self.gallery_window = None
self.rebuild_instance_menu()
def _start_thread(self, thread):
self._running_threads.add(thread)
thread.finished.connect(thread.deleteLater)
thread.finished.connect(lambda: self._running_threads.discard(thread))
self.active_thread = thread
thread.start()
def rebuild_instance_menu(self):
self.instance_menu.clear()
config = get_env_config()
instances = config.get("instances", [])
active_idx = config.get("active_instance_index", 0)
active_name = config.get("active_instance_name", "Default Instance")
self.instance_menu.setTitle(f"Instance: {active_name}")
for idx, inst in enumerate(instances):
name = inst.get("name", f"Instance {idx + 1}")
url = inst.get("api_url", "")
action = QAction(name, self.instance_menu)
if url:
action.setToolTip(url)
action.setCheckable(True)
if idx == active_idx:
action.setChecked(True)
action.triggered.connect(lambda checked=False, i=idx: self.switch_active_instance(i))
self.instance_menu.addAction(action)
self.instance_menu.addSeparator()
manage_action = QAction("Manage Instances...", self.instance_menu)
manage_action.triggered.connect(self.on_open_settings)
self.instance_menu.addAction(manage_action)
def switch_active_instance(self, index):
config = get_env_config()
instances = config.get("instances", [])
if 0 <= index < len(instances):
config["active_instance_index"] = index
active_inst = instances[index]
config["api_url"] = active_inst.get("api_url", "")
config["api_key"] = active_inst.get("api_key", "")
save_config(config)
name = active_inst.get("name", "Instance")
print(f"[f0ckm-gui] Switched active instance to '{name}' ({active_inst.get('api_url', '')})", flush=True)
self.show_message("f0ckm Instance Switched", f"Active instance changed to: {name}", QSystemTrayIcon.Information)
self.rebuild_instance_menu()
if self.settings_dialog and self.settings_dialog.isVisible():
self.settings_dialog.load_current_settings()
def enqueue_targets(self, targets): def enqueue_targets(self, targets):
if isinstance(targets, str): if isinstance(targets, str):
targets = [targets] targets = [targets]
@@ -1554,12 +1838,7 @@ class SystemTrayApp(QObject):
self.upload_file_direct(target_str) self.upload_file_direct(target_str)
def _on_single_task_complete(self): def _on_single_task_complete(self):
if self.active_thread: self.active_thread = None
try:
self.active_thread.deleteLater()
except Exception:
pass
self.active_thread = None
QTimer.singleShot(50, self.process_next_in_queue) QTimer.singleShot(50, self.process_next_in_queue)
def on_notification_clicked(self): def on_notification_clicked(self):
@@ -1673,9 +1952,9 @@ class SystemTrayApp(QObject):
rem_str = f"\n({len(self.upload_queue)} waiting in queue)" if self.upload_queue else "" rem_str = f"\n({len(self.upload_queue)} waiting in queue)" if self.upload_queue else ""
self.tray.setToolTip(f"Uploading URL {batch_prefix}to f0ckm...\n{url[:45]}{rem_str}") self.tray.setToolTip(f"Uploading URL {batch_prefix}to f0ckm...\n{url[:45]}{rem_str}")
self.active_thread = URLPostThread(api_url, api_key, url, rating, tags, visibility, is_oc) thread = URLPostThread(api_url, api_key, url, rating, tags, visibility, is_oc)
self.active_thread.finished.connect(lambda ok, res: self.on_url_post_finished(ok, res, url)) thread.finished.connect(lambda ok, res: self.on_url_post_finished(ok, res, url))
self.active_thread.start() self._start_thread(thread)
def on_url_post_finished(self, ok, result, url): def on_url_post_finished(self, ok, result, url):
if ok: if ok:
@@ -1710,10 +1989,10 @@ class SystemTrayApp(QObject):
print(f"[f0ckm-gui] JSON parse notice: {e}", flush=True) print(f"[f0ckm-gui] JSON parse notice: {e}", flush=True)
print(f"[f0ckm-gui] Direct URL API notice: Falling back to local download of '{url}'", flush=True) print(f"[f0ckm-gui] Direct URL API notice: Falling back to local download of '{url}'", flush=True)
self.active_thread = StreamingDownloadThread(url) thread = StreamingDownloadThread(url)
self.active_thread.progress.connect(self.on_download_progress) thread.progress.connect(self.on_download_progress)
self.active_thread.finished.connect(lambda ok_dl, res_dl: self.on_download_finished(ok_dl, res_dl, url)) thread.finished.connect(lambda ok_dl, res_dl: self.on_download_finished(ok_dl, res_dl, url))
self.active_thread.start() self._start_thread(thread)
def on_download_progress(self, bytes_received, bytes_total): def on_download_progress(self, bytes_received, bytes_total):
batch_prefix = f"[{self.current_item_index}/{self.total_queue_count}] " if self.total_queue_count > 1 else "" batch_prefix = f"[{self.current_item_index}/{self.total_queue_count}] " if self.total_queue_count > 1 else ""
@@ -1775,10 +2054,10 @@ class SystemTrayApp(QObject):
rem_str = f"\n({len(self.upload_queue)} waiting in queue)" if self.upload_queue else "" rem_str = f"\n({len(self.upload_queue)} waiting in queue)" if self.upload_queue else ""
self.tray.setToolTip(f"Uploading {batch_prefix}{filename}...\n0% (0 B){rem_str}") self.tray.setToolTip(f"Uploading {batch_prefix}{filename}...\n0% (0 B){rem_str}")
self.active_thread = StreamingUploadThread(api_url, api_key, file_path, rating, tags, visibility, is_oc) thread = StreamingUploadThread(api_url, api_key, file_path, rating, tags, visibility, is_oc)
self.active_thread.progress.connect(self.on_upload_progress) thread.progress.connect(self.on_upload_progress)
self.active_thread.finished.connect(lambda ok, resp: self.on_upload_finished(ok, resp, file_path)) thread.finished.connect(lambda ok, resp: self.on_upload_finished(ok, resp, file_path))
self.active_thread.start() self._start_thread(thread)
def on_upload_progress(self, bytes_sent, bytes_total): def on_upload_progress(self, bytes_sent, bytes_total):
if bytes_total > 0: if bytes_total > 0:
@@ -2079,8 +2358,11 @@ def main():
else: else:
if not cfg.get("api_url") or not cfg.get("api_key"): if not cfg.get("api_url") or not cfg.get("api_key"):
print("[f0ckm-gui] First launch detected (missing API credentials). Opening settings dialog...", flush=True) print("[f0ckm-gui] First launch detected (missing API credentials). Opening settings dialog...", flush=True)
QTimer.singleShot(400, tray_app.on_open_settings) signal.signal(signal.SIGINT, signal.SIG_DFL)
sig_timer = QTimer()
sig_timer.timeout.connect(lambda: None)
sig_timer.start(500)
sys.exit(app.exec()) sys.exit(app.exec())
if __name__ == "__main__": if __name__ == "__main__":