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

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