update browser extension and app

This commit is contained in:
2026-08-13 17:21:58 +02:00
parent 7e723ae821
commit 9050faf0a4
14 changed files with 809 additions and 97 deletions

View File

@@ -0,0 +1,42 @@
package com.fuggloader.app
import org.json.JSONObject
import java.util.UUID
data class Instance(
val id: String = UUID.randomUUID().toString(),
var name: String,
var apiUrl: String,
var apiKey: String,
var defaultRating: String = "none",
var defaultTags: String = "",
var defaultVisibility: String = "0"
) {
fun toJsonObject(): JSONObject {
return JSONObject().apply {
put("id", id)
put("name", name)
put("apiUrl", apiUrl)
put("apiKey", apiKey)
put("defaultRating", defaultRating)
put("defaultTags", defaultTags)
put("defaultVisibility", defaultVisibility)
}
}
override fun toString(): String = name
companion object {
fun fromJsonObject(json: JSONObject): Instance {
return Instance(
id = json.optString("id", UUID.randomUUID().toString()),
name = json.optString("name", "Default"),
apiUrl = json.optString("apiUrl", ""),
apiKey = json.optString("apiKey", ""),
defaultRating = json.optString("defaultRating", "none"),
defaultTags = json.optString("defaultTags", ""),
defaultVisibility = json.optString("defaultVisibility", "0")
)
}
}
}

View File

@@ -2,6 +2,8 @@ package com.fuggloader.app
import android.os.Bundle
import android.util.Log
import android.view.View
import android.widget.AdapterView
import android.widget.ArrayAdapter
import android.widget.Toast
import androidx.appcompat.app.AppCompatActivity
@@ -9,13 +11,15 @@ 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
private lateinit var instanceAdapter: ArrayAdapter<Instance>
private var currentInstances: List<Instance> = emptyList()
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
@@ -27,7 +31,7 @@ class MainActivity : AppCompatActivity() {
setupRatingSpinner()
setupVisibilitySpinner()
loadSettings()
setupInstanceSpinner()
binding.btnSave.setOnClickListener {
saveSettings()
@@ -35,9 +39,16 @@ class MainActivity : AppCompatActivity() {
}
binding.btnTestConnection.setOnClickListener {
saveSettings()
testConnection()
}
binding.btnAddInstance.setOnClickListener {
addNewInstance()
}
binding.btnDeleteInstance.setOnClickListener {
deleteCurrentInstance()
}
}
private fun setupRatingSpinner() {
@@ -52,19 +63,45 @@ class MainActivity : AppCompatActivity() {
binding.spinnerVisibility.adapter = adapter
}
private fun loadSettings() {
binding.etApiUrl.setText(prefs.apiUrl)
binding.etApiKey.setText(prefs.apiKey)
binding.etTags.setText(prefs.defaultTags)
private fun setupInstanceSpinner() {
currentInstances = prefs.getInstances()
if (currentInstances.isEmpty()) {
val default = Instance(name = "Default", apiUrl = "", apiKey = "")
prefs.addInstance(default)
currentInstances = prefs.getInstances()
}
when (prefs.defaultRating) {
instanceAdapter = ArrayAdapter(this, android.R.layout.simple_spinner_dropdown_item, currentInstances)
binding.spinnerInstances.adapter = instanceAdapter
val activeId = prefs.activeInstanceId
val activeIndex = currentInstances.indexOfFirst { it.id == activeId }.coerceAtLeast(0)
binding.spinnerInstances.setSelection(activeIndex)
binding.spinnerInstances.onItemSelectedListener = object : AdapterView.OnItemSelectedListener {
override fun onItemSelected(parent: AdapterView<*>?, view: View?, position: Int, id: Long) {
val selected = currentInstances[position]
prefs.activeInstanceId = selected.id
loadInstanceData(selected)
}
override fun onNothingSelected(parent: AdapterView<*>?) {}
}
}
private fun loadInstanceData(instance: Instance) {
binding.etInstanceName.setText(instance.name)
binding.etApiUrl.setText(instance.apiUrl)
binding.etApiKey.setText(instance.apiKey)
binding.etTags.setText(instance.defaultTags)
when (instance.defaultRating) {
"sfw" -> binding.spinnerRating.setSelection(1)
"nsfw" -> binding.spinnerRating.setSelection(2)
"nsfl" -> binding.spinnerRating.setSelection(3)
else -> binding.spinnerRating.setSelection(0)
}
when (prefs.defaultVisibility) {
when (instance.defaultVisibility) {
"1" -> binding.spinnerVisibility.setSelection(1)
"2" -> binding.spinnerVisibility.setSelection(2)
else -> binding.spinnerVisibility.setSelection(0)
@@ -72,22 +109,54 @@ class MainActivity : AppCompatActivity() {
}
private fun saveSettings() {
prefs.apiUrl = binding.etApiUrl.text.toString().trim()
prefs.apiKey = binding.etApiKey.text.toString().trim()
prefs.defaultTags = binding.etTags.text.toString().trim()
val currentInstance = prefs.getActiveInstance() ?: return
currentInstance.name = binding.etInstanceName.text.toString().trim().ifBlank { "Untitled" }
currentInstance.apiUrl = binding.etApiUrl.text.toString().trim()
currentInstance.apiKey = binding.etApiKey.text.toString().trim()
currentInstance.defaultTags = binding.etTags.text.toString().trim()
prefs.defaultRating = when (binding.spinnerRating.selectedItemPosition) {
currentInstance.defaultRating = when (binding.spinnerRating.selectedItemPosition) {
1 -> "sfw"
2 -> "nsfw"
3 -> "nsfl"
else -> "none"
}
prefs.defaultVisibility = binding.spinnerVisibility.selectedItemPosition.toString()
currentInstance.defaultVisibility = binding.spinnerVisibility.selectedItemPosition.toString()
prefs.updateInstance(currentInstance)
// Refresh spinner to show updated name
val pos = binding.spinnerInstances.selectedItemPosition
currentInstances = prefs.getInstances()
instanceAdapter = ArrayAdapter(this, android.R.layout.simple_spinner_dropdown_item, currentInstances)
binding.spinnerInstances.adapter = instanceAdapter
binding.spinnerInstances.setSelection(pos)
}
private fun addNewInstance() {
val newInstance = Instance(name = "New Instance", apiUrl = "", apiKey = "")
prefs.addInstance(newInstance)
prefs.activeInstanceId = newInstance.id
setupInstanceSpinner()
}
private fun deleteCurrentInstance() {
val current = prefs.getActiveInstance() ?: return
if (currentInstances.size <= 1) {
Toast.makeText(this, "Cannot delete the only instance", Toast.LENGTH_SHORT).show()
return
}
prefs.deleteInstance(current.id)
setupInstanceSpinner()
Toast.makeText(this, "Instance deleted", Toast.LENGTH_SHORT).show()
}
private fun testConnection() {
if (!prefs.isConfigured()) {
val currentInstance = prefs.getActiveInstance()
if (currentInstance == null || currentInstance.apiUrl.isBlank() || currentInstance.apiKey.isBlank()) {
Toast.makeText(this, "Please enter both API URL and API Key", Toast.LENGTH_LONG).show()
return
}
@@ -96,7 +165,7 @@ class MainActivity : AppCompatActivity() {
binding.btnTestConnection.text = "Testing..."
CoroutineScope(Dispatchers.Main).launch {
val result = uploader.testConnection()
val result = uploader.testConnection(currentInstance)
binding.btnTestConnection.isEnabled = true
binding.btnTestConnection.text = "Test"

View File

@@ -2,31 +2,112 @@ package com.fuggloader.app
import android.content.Context
import android.content.SharedPreferences
import org.json.JSONArray
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()
companion object {
private const val KEY_INSTANCES = "instances_json"
private const val KEY_ACTIVE_INSTANCE_ID = "active_instance_id"
var apiKey: String
get() = prefs.getString("api_key", "") ?: ""
set(value) = prefs.edit().putString("api_key", value).apply()
// Old keys for migration
private const val OLD_API_URL = "api_url"
private const val OLD_API_KEY = "api_key"
private const val OLD_RATING = "default_rating"
private const val OLD_TAGS = "default_tags"
private const val OLD_VISIBILITY = "default_visibility"
}
var defaultRating: String
get() = prefs.getString("default_rating", "none") ?: "none"
set(value) = prefs.edit().putString("default_rating", value).apply()
var activeInstanceId: String?
get() = prefs.getString(KEY_ACTIVE_INSTANCE_ID, null)
set(value) = prefs.edit().putString(KEY_ACTIVE_INSTANCE_ID, value).apply()
var defaultTags: String
get() = prefs.getString("default_tags", "android,upload") ?: "android,upload"
set(value) = prefs.edit().putString("default_tags", value).apply()
fun getInstances(): List<Instance> {
val jsonStr = prefs.getString(KEY_INSTANCES, null)
if (jsonStr == null) {
// Check for migration
val oldUrl = prefs.getString(OLD_API_URL, null)
if (oldUrl != null) {
val instance = Instance(
name = "Default",
apiUrl = oldUrl,
apiKey = prefs.getString(OLD_API_KEY, "") ?: "",
defaultRating = prefs.getString(OLD_RATING, "none") ?: "none",
defaultTags = prefs.getString(OLD_TAGS, "android,upload") ?: "android,upload",
defaultVisibility = prefs.getString(OLD_VISIBILITY, "0") ?: "0"
)
val list = listOf(instance)
saveInstances(list)
activeInstanceId = instance.id
var defaultVisibility: String
get() = prefs.getString("default_visibility", "0") ?: "0"
set(value) = prefs.edit().putString("default_visibility", value).apply()
// Clear old settings to avoid re-migration
prefs.edit()
.remove(OLD_API_URL)
.remove(OLD_API_KEY)
.remove(OLD_RATING)
.remove(OLD_TAGS)
.remove(OLD_VISIBILITY)
.apply()
return list
}
return emptyList()
}
val list = mutableListOf<Instance>()
try {
val array = JSONArray(jsonStr)
for (i in 0 until array.length()) {
list.add(Instance.fromJsonObject(array.getJSONObject(i)))
}
} catch (e: Exception) {
e.printStackTrace()
}
return list
}
fun saveInstances(instances: List<Instance>) {
val array = JSONArray()
instances.forEach { array.put(it.toJsonObject()) }
prefs.edit().putString(KEY_INSTANCES, array.toString()).apply()
}
fun addInstance(instance: Instance) {
val instances = getInstances().toMutableList()
instances.add(instance)
saveInstances(instances)
}
fun updateInstance(instance: Instance) {
val instances = getInstances().toMutableList()
val index = instances.indexOfFirst { it.id == instance.id }
if (index != -1) {
instances[index] = instance
saveInstances(instances)
}
}
fun deleteInstance(id: String) {
val instances = getInstances().toMutableList()
instances.removeAll { it.id == id }
saveInstances(instances)
if (activeInstanceId == id) {
activeInstanceId = instances.firstOrNull()?.id
}
}
fun getActiveInstance(): Instance? {
val instances = getInstances()
if (instances.isEmpty()) return null
val id = activeInstanceId ?: return instances.firstOrNull()
return instances.find { it.id == id } ?: instances.firstOrNull()
}
fun isConfigured(): Boolean {
return apiUrl.isNotBlank() && apiKey.isNotBlank()
return getActiveInstance()?.let {
it.apiUrl.isNotBlank() && it.apiKey.isNotBlank()
} ?: false
}
}

View File

@@ -8,6 +8,7 @@ import android.net.Uri
import android.os.Bundle
import android.view.View
import android.widget.Toast
import androidx.appcompat.app.AlertDialog
import androidx.appcompat.app.AppCompatActivity
import com.fuggloader.app.databinding.ActivityShareBinding
import kotlinx.coroutines.CoroutineScope
@@ -57,10 +58,32 @@ class ShareActivity : AppCompatActivity() {
finish()
}
handleShareIntent(intent)
prepareShare(intent)
}
private fun handleShareIntent(intent: Intent) {
private fun prepareShare(intent: Intent) {
val instances = prefs.getInstances()
if (instances.size > 1) {
val names = instances.map { it.name }.toTypedArray()
AlertDialog.Builder(this)
.setTitle("Select Instance")
.setItems(names) { _, which ->
handleShareIntent(instances[which], intent)
}
.setNegativeButton("Cancel") { _, _ -> finish() }
.setCancelable(false)
.show()
} else {
val target = instances.firstOrNull()
if (target != null) {
handleShareIntent(target, intent)
} else {
showError("No instance configured")
}
}
}
private fun handleShareIntent(instance: Instance, intent: Intent) {
val action = intent.action
val type = intent.type
@@ -69,11 +92,11 @@ class ShareActivity : AppCompatActivity() {
val sharedText = intent.getStringExtra(Intent.EXTRA_TEXT)
if (!sharedText.isNull_or_blank_url()) {
val targetUrl = extractUrl(sharedText!!)
processUrlUpload(targetUrl)
processUrlUpload(instance, targetUrl)
} else {
val streamUri = intent.getParcelableExtra<Uri>(Intent.EXTRA_STREAM)
if (streamUri != null) {
processFileUpload(streamUri)
processFileUpload(instance, streamUri)
} else {
showError("No valid file or URL found in share intent")
}
@@ -81,7 +104,7 @@ class ShareActivity : AppCompatActivity() {
} else {
val streamUri = intent.getParcelableExtra<Uri>(Intent.EXTRA_STREAM)
if (streamUri != null) {
processFileUpload(streamUri)
processFileUpload(instance, streamUri)
} else {
showError("No stream URI found for file share")
}
@@ -90,7 +113,7 @@ class ShareActivity : AppCompatActivity() {
val uris = intent.getParcelableArrayListExtra<Uri>(Intent.EXTRA_STREAM)
if (!uris.isNullOrEmpty()) {
// Upload first file in batch for now
processFileUpload(uris[0])
processFileUpload(instance, uris[0])
} else {
showError("No files found in multi-share intent")
}
@@ -116,8 +139,8 @@ class ShareActivity : AppCompatActivity() {
return trimmed
}
private fun processFileUpload(uri: Uri) {
binding.tvStatus.text = "Uploading file..."
private fun processFileUpload(instance: Instance, uri: Uri) {
binding.tvStatus.text = "Uploading to ${instance.name}..."
binding.progressBar.visibility = View.VISIBLE
binding.progressBar.isIndeterminate = false
binding.progressBar.progress = 0
@@ -126,6 +149,7 @@ class ShareActivity : AppCompatActivity() {
val result = uploader.uploadFile(
contentResolver = contentResolver,
uri = uri,
instance = instance,
onProgress = { pct ->
runOnUiThread {
binding.progressBar.progress = pct
@@ -141,14 +165,14 @@ class ShareActivity : AppCompatActivity() {
}
}
private fun processUrlUpload(url: String) {
binding.tvStatus.text = "Uploading URL..."
private fun processUrlUpload(instance: Instance, url: String) {
binding.tvStatus.text = "Uploading to ${instance.name}..."
binding.progressBar.visibility = View.VISIBLE
binding.progressBar.isIndeterminate = true
binding.tvPercent.text = ""
CoroutineScope(Dispatchers.Main).launch {
val result = uploader.uploadUrl(url)
val result = uploader.uploadUrl(url, instance)
result.fold(
onSuccess = { postUrl -> showSuccess(postUrl) },
onFailure = { err -> showError(err.message ?: "URL Upload failed") }

View File

@@ -29,12 +29,13 @@ class UploaderService(private val prefs: PreferencesManager) {
suspend fun uploadFile(
contentResolver: ContentResolver,
uri: Uri,
rating: String = prefs.defaultRating,
tags: String = prefs.defaultTags,
visibility: String = prefs.defaultVisibility,
instance: Instance? = null,
onProgress: (percent: Int) -> Unit
): Result<String> = withContext(Dispatchers.IO) {
Log.d("UploaderService", "Starting uploadFile for $uri")
val targetInstance = instance ?: prefs.getActiveInstance()
?: return@withContext Result.failure(Exception("No instance configured"))
Log.d("UploaderService", "Starting uploadFile for $uri to ${targetInstance.apiUrl}")
try {
val fileName = getFileName(contentResolver, uri) ?: "upload_${System.currentTimeMillis()}"
val mimeType = contentResolver.getType(uri) ?: "application/octet-stream"
@@ -69,16 +70,16 @@ class UploaderService(private val prefs: PreferencesManager) {
.setType(MultipartBody.FORM)
.addFormDataPart("file", fileName, requestBody)
if (rating != "none") {
multipartBuilder.addFormDataPart("rating", rating)
if (targetInstance.defaultRating != "none") {
multipartBuilder.addFormDataPart("rating", targetInstance.defaultRating)
}
multipartBuilder.addFormDataPart("tags", tags)
.addFormDataPart("visibility", visibility)
multipartBuilder.addFormDataPart("tags", targetInstance.defaultTags)
.addFormDataPart("visibility", targetInstance.defaultVisibility)
val request = Request.Builder()
.url(prefs.apiUrl)
.addHeader("X-Api-Key", prefs.apiKey)
.url(targetInstance.apiUrl)
.addHeader("X-Api-Key", targetInstance.apiKey)
.addHeader("User-Agent", "fuggloader-android/1.0")
.post(multipartBuilder.build())
.build()
@@ -101,19 +102,20 @@ class UploaderService(private val prefs: PreferencesManager) {
suspend fun uploadUrl(
url: String,
rating: String = prefs.defaultRating,
tags: String = prefs.defaultTags,
visibility: String = prefs.defaultVisibility
instance: Instance? = null
): Result<String> = withContext(Dispatchers.IO) {
Log.d("UploaderService", "Starting uploadUrl for $url")
val targetInstance = instance ?: prefs.getActiveInstance()
?: return@withContext Result.failure(Exception("No instance configured"))
Log.d("UploaderService", "Starting uploadUrl for $url to ${targetInstance.apiUrl}")
try {
val json = JSONObject().apply {
put("url", url)
if (rating != "none") {
put("rating", rating)
if (targetInstance.defaultRating != "none") {
put("rating", targetInstance.defaultRating)
}
put("tags", tags)
put("visibility", visibility)
put("tags", targetInstance.defaultTags)
put("visibility", targetInstance.defaultVisibility)
}
val requestBody = RequestBody.create(
@@ -122,8 +124,8 @@ class UploaderService(private val prefs: PreferencesManager) {
)
val request = Request.Builder()
.url(prefs.apiUrl)
.addHeader("X-Api-Key", prefs.apiKey)
.url(targetInstance.apiUrl)
.addHeader("X-Api-Key", targetInstance.apiKey)
.addHeader("User-Agent", "fuggloader-android/1.0")
.post(requestBody)
.build()
@@ -143,12 +145,15 @@ class UploaderService(private val prefs: PreferencesManager) {
}
}
suspend fun testConnection(): Result<String> = withContext(Dispatchers.IO) {
Log.d("UploaderService", "Starting testConnection ping")
suspend fun testConnection(instance: Instance? = null): Result<String> = withContext(Dispatchers.IO) {
val targetInstance = instance ?: prefs.getActiveInstance()
?: return@withContext Result.failure(Exception("No instance configured"))
Log.d("UploaderService", "Starting testConnection ping for ${targetInstance.apiUrl}")
try {
val request = Request.Builder()
.url(prefs.apiUrl)
.addHeader("X-Api-Key", prefs.apiKey)
.url(targetInstance.apiUrl)
.addHeader("X-Api-Key", targetInstance.apiKey)
.addHeader("User-Agent", "fuggloader-android/1.0")
.post(ByteArray(0).toRequestBody(null))
.build()

View File

@@ -28,11 +28,76 @@
android:textColor="#A0A0B0"
android:textSize="14sp" />
<!-- Instance Selection -->
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="20dp"
android:text="Select Instance"
android:textColor="#E0E0E0"
android:textStyle="bold" />
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="6dp"
android:gravity="center_vertical"
android:orientation="horizontal">
<Spinner
android:id="@+id/spinnerInstances"
android:layout_width="0dp"
android:layout_height="48dp"
android:layout_weight="1"
android:background="#1E1E24" />
<ImageButton
android:id="@+id/btnAddInstance"
android:layout_width="48dp"
android:layout_height="48dp"
android:layout_marginStart="8dp"
android:background="#1E1E24"
android:contentDescription="Add Instance"
android:src="@android:drawable/ic_input_add"
app:tint="#FFFFFF" />
<ImageButton
android:id="@+id/btnDeleteInstance"
android:layout_width="48dp"
android:layout_height="48dp"
android:layout_marginStart="8dp"
android:background="#1E1E24"
android:contentDescription="Delete Instance"
android:src="@android:drawable/ic_menu_delete"
app:tint="#FF4444" />
</LinearLayout>
<!-- Instance Name -->
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="16dp"
android:text="Instance Name"
android:textColor="#E0E0E0"
android:textStyle="bold" />
<EditText
android:id="@+id/etInstanceName"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="6dp"
android:background="#1E1E24"
android:hint="My Server"
android:inputType="text"
android:padding="12dp"
android:textColor="#FFFFFF"
android:textColorHint="#606070" />
<!-- API Endpoint URL -->
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="24dp"
android:layout_marginTop="16dp"
android:text="API Endpoint URL"
android:textColor="#E0E0E0"
android:textStyle="bold" />