diff --git a/README.md b/README.md
index e15b59f..80aeb98 100644
--- a/README.md
+++ b/README.md
@@ -50,6 +50,12 @@ Shows a preview with custom options and copies the URL to the clipboard after up
+> [!WARNING]
+> Google Play is currently in Closed Testing.
+> To be included see [this discussion](https://github.com/cssnr/zipline-android/discussions/25).
+
+[](https://play.google.com/store/apps/details?id=org.cssnr.zipline)
+
_Note: Until published on the play store, you may need to allow installation of apps from unknown sources._
Downloading and Installing the [apk](https://github.com/cssnr/zipline-android/releases/latest/download/app-release.apk)
@@ -85,6 +91,7 @@ Additionally, the URL is copied to the clipboard and the preview is show in the
- Share or Open any file or URL to your Zipline server.
- Single file previews most media with custom name option.
- Multiple file upload previews, options and file selector.
+- Widget with stats, custom update interval, upload button.
### Planned
@@ -203,13 +210,13 @@ For more details, see the [ADB Documentation](https://developer.android.com/tool
## Google Services
This app uses Firebase Google Services. Building requires a valid `google-services.json` file in the `app` directory.
-You must add `org.cssnr.zipline.dev` to a Firebase campaign here: https://firebase.google.com/
+You must add `org.cssnr.zipline` to a Firebase campaign here: https://firebase.google.com/
To enable/disable Firebase DebugView use the following commands:
```shell
# set
-adb shell setprop debug.firebase.analytics.app org.cssnr.zipline.dev
+adb shell setprop debug.firebase.analytics.app org.cssnr.zipline
# unset
adb shell setprop debug.firebase.analytics.app .none.
diff --git a/app/build.gradle.kts b/app/build.gradle.kts
index 37c0200..eeb1291 100644
--- a/app/build.gradle.kts
+++ b/app/build.gradle.kts
@@ -1,7 +1,7 @@
plugins {
alias(libs.plugins.android.application)
alias(libs.plugins.kotlin.android)
- //alias(libs.plugins.ksp)
+ alias(libs.plugins.ksp)
alias(libs.plugins.google.services)
alias(libs.plugins.firebase.crashlytics)
}
@@ -62,6 +62,9 @@ dependencies {
implementation(libs.androidx.navigation.fragment.ktx)
implementation(libs.androidx.navigation.ui.ktx)
implementation(libs.androidx.preference.ktx)
+ implementation(libs.androidx.work.runtime.ktx)
+ implementation(libs.androidx.room.ktx)
+ implementation(libs.androidx.room.runtime)
implementation(platform(libs.firebase.bom))
implementation(libs.firebase.analytics)
implementation(libs.firebase.crashlytics)
@@ -74,6 +77,7 @@ dependencies {
implementation(libs.media3.ui)
implementation(libs.media3.ui.compose)
implementation(libs.taptargetview)
+ ksp(libs.androidx.room.compiler)
testImplementation(libs.junit)
androidTestImplementation(libs.androidx.junit)
androidTestImplementation(libs.androidx.espresso.core)
diff --git a/app/proguard-rules.pro b/app/proguard-rules.pro
index bd9b891..e4fa445 100644
--- a/app/proguard-rules.pro
+++ b/app/proguard-rules.pro
@@ -20,7 +20,8 @@
# hide the original source file name.
#-renamesourcefileattribute SourceFile
-# Logging
+
+## Logging
-assumenosideeffects class android.util.Log {
public static int d(...);
public static int v(...);
diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index 547fd0d..01e8aff 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -63,6 +63,23 @@
android:name="firebase_crashlytics_collection_enabled"
android:value="${firebaseCrashlyticsEnabled}" />
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/src/main/java/org/cssnr/zipline/MainActivity.kt b/app/src/main/java/org/cssnr/zipline/MainActivity.kt
index b5acace..c94b325 100644
--- a/app/src/main/java/org/cssnr/zipline/MainActivity.kt
+++ b/app/src/main/java/org/cssnr/zipline/MainActivity.kt
@@ -1,8 +1,10 @@
package org.cssnr.zipline
import android.annotation.SuppressLint
+import android.appwidget.AppWidgetManager
import android.content.ClipData
import android.content.ClipboardManager
+import android.content.ComponentName
import android.content.Context
import android.content.Context.CLIPBOARD_SERVICE
import android.content.Intent
@@ -24,8 +26,15 @@ import androidx.navigation.NavOptions
import androidx.navigation.fragment.NavHostFragment
import androidx.navigation.ui.NavigationUI
import androidx.preference.PreferenceManager
+import androidx.work.ExistingPeriodicWorkPolicy
+import androidx.work.PeriodicWorkRequestBuilder
+import androidx.work.WorkManager
import org.cssnr.zipline.databinding.ActivityMainBinding
+import org.cssnr.zipline.widget.WidgetProvider
+import org.cssnr.zipline.work.APP_WORKER_CONSTRAINTS
+import org.cssnr.zipline.work.AppWorker
import java.net.URL
+import java.util.concurrent.TimeUnit
class MainActivity : AppCompatActivity() {
@@ -60,6 +69,27 @@ class MainActivity : AppCompatActivity() {
// Set Default Preferences
PreferenceManager.setDefaultValues(this, R.xml.preferences, false)
+ PreferenceManager.setDefaultValues(this, R.xml.preferences_widget, false)
+
+ // TODO: Improve initialization of the WorkRequest
+ val workInterval = preferences.getString("work_interval", null) ?: "0"
+ Log.i("Main[onCreate]", "workInterval: $workInterval")
+ if (workInterval != "0") {
+ val workRequest =
+ PeriodicWorkRequestBuilder(workInterval.toLong(), TimeUnit.MINUTES)
+ .setConstraints(APP_WORKER_CONSTRAINTS)
+ .build()
+ Log.i("Main[onCreate]", "workRequest: $workRequest")
+ WorkManager.getInstance(this).enqueueUniquePeriodicWork(
+ "app_worker",
+ ExistingPeriodicWorkPolicy.KEEP,
+ workRequest
+ )
+ } else {
+ // TODO: Confirm this is necessary...
+ Log.i("Main[onCreate]", "Ensuring Work is Disabled")
+ WorkManager.getInstance(this).cancelUniqueWork("app_worker")
+ }
// Handle Custom Navigation Items
binding.navView.setNavigationItemSelectedListener { menuItem ->
@@ -102,12 +132,13 @@ class MainActivity : AppCompatActivity() {
override fun onNewIntent(intent: Intent) {
super.onNewIntent(intent)
- Log.d("onNewIntent", "intent.data: ${intent.data}")
+ val action = intent.action
+ Log.d("onNewIntent", "action: $action")
+ Log.d("onNewIntent", "data: ${intent.data}")
Log.d("onNewIntent", "intent.type: ${intent.type}")
- Log.d("onNewIntent", "intent.action: ${intent.action}")
val extraText = intent.getStringExtra(Intent.EXTRA_TEXT)
- Log.d("onNewIntent", "extraText: $extraText")
+ Log.d("onNewIntent", "extraText: ${extraText?.take(100)}")
val savedUrl = preferences.getString("ziplineUrl", null)
val authToken = preferences.getString("ziplineToken", null)
@@ -123,7 +154,7 @@ class MainActivity : AppCompatActivity() {
.build()
)
- } else if (Intent.ACTION_MAIN == intent.action) {
+ } else if (Intent.ACTION_MAIN == action) {
Log.d("onNewIntent", "ACTION_MAIN")
binding.drawerLayout.closeDrawers()
@@ -157,7 +188,7 @@ class MainActivity : AppCompatActivity() {
filePickerLauncher.launch(arrayOf("*/*"))
}
- } else if (Intent.ACTION_SEND == intent.action) {
+ } else if (Intent.ACTION_SEND == action) {
Log.d("onNewIntent", "ACTION_SEND")
val fileUri = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
@@ -169,7 +200,7 @@ class MainActivity : AppCompatActivity() {
Log.d("onNewIntent", "File URI: $fileUri")
if (fileUri == null && !extraText.isNullOrEmpty()) {
- Log.d("onNewIntent", "SEND TEXT DETECTED: $extraText")
+ Log.d("onNewIntent", "SEND TEXT DETECTED: ${extraText.take(100)}")
//if (extraText.lowercase().startsWith("http")) {
//if (Patterns.WEB_URL.matcher(extraText).matches()) {
if (isURL(extraText)) {
@@ -204,7 +235,7 @@ class MainActivity : AppCompatActivity() {
showPreview(fileUri)
}
- } else if (Intent.ACTION_SEND_MULTIPLE == intent.action) {
+ } else if (Intent.ACTION_SEND_MULTIPLE == action) {
Log.d("onNewIntent", "ACTION_SEND_MULTIPLE")
val fileUris = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
@@ -221,18 +252,33 @@ class MainActivity : AppCompatActivity() {
}
showMultiPreview(fileUris)
- } else if (Intent.ACTION_VIEW == intent.action) {
+ } else if (Intent.ACTION_VIEW == action) {
Log.d("onNewIntent", "ACTION_VIEW")
Log.d("onNewIntent", "File URI: ${intent.data}")
showPreview(intent.data)
+ } else if ("UPLOAD_FILE" == action) {
+ Log.d("handleIntent", "UPLOAD_FILE")
+
+ filePickerLauncher.launch(arrayOf("*/*"))
+
} else {
- Toast.makeText(this, "That's a Bug!", Toast.LENGTH_SHORT).show()
- Log.w("onNewIntent", "BUG: UNKNOWN intent.action: ${intent.action}")
+ Toast.makeText(this, "That's a Bug!", Toast.LENGTH_LONG).show()
+ Log.w("onNewIntent", "UNKNOWN INTENT - action: $action")
}
}
+ override fun onStop() {
+ super.onStop()
+ Log.d("Main[onStop]", "MainActivity - onStop")
+ // Update Widget
+ val appWidgetManager = AppWidgetManager.getInstance(this)
+ val componentName = ComponentName(this, WidgetProvider::class.java)
+ val ids = appWidgetManager.getAppWidgetIds(componentName)
+ WidgetProvider().onUpdate(this, appWidgetManager, ids)
+ }
+
private fun isURL(url: String): Boolean {
return try {
URL(url)
diff --git a/app/src/main/java/org/cssnr/zipline/api/ZiplineApi.kt b/app/src/main/java/org/cssnr/zipline/api/ZiplineApi.kt
index 13fbea6..afd048d 100644
--- a/app/src/main/java/org/cssnr/zipline/api/ZiplineApi.kt
+++ b/app/src/main/java/org/cssnr/zipline/api/ZiplineApi.kt
@@ -1,11 +1,11 @@
package org.cssnr.zipline.api
import android.content.Context
-import android.content.SharedPreferences
import android.util.Log
import android.webkit.CookieManager
import androidx.core.content.edit
import androidx.preference.PreferenceManager
+import com.google.gson.annotations.SerializedName
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import okhttp3.Cookie
@@ -17,6 +17,7 @@ import okhttp3.MediaType.Companion.toMediaTypeOrNull
import okhttp3.MultipartBody
import okhttp3.OkHttpClient
import okhttp3.RequestBody.Companion.toRequestBody
+import org.cssnr.zipline.R
import retrofit2.Response
import retrofit2.Retrofit
import retrofit2.converter.gson.GsonConverterFactory
@@ -34,8 +35,7 @@ class ZiplineApi(private val context: Context, url: String? = null) {
private var ziplineUrl: String
private var ziplineToken: String
- private val preferences: SharedPreferences =
- PreferenceManager.getDefaultSharedPreferences(context)
+ private val preferences by lazy { PreferenceManager.getDefaultSharedPreferences(context) }
private lateinit var cookieJar: SimpleCookieJar
private lateinit var client: OkHttpClient
@@ -43,24 +43,24 @@ class ZiplineApi(private val context: Context, url: String? = null) {
init {
ziplineUrl = url ?: preferences.getString("ziplineUrl", null) ?: ""
ziplineToken = preferences.getString("ziplineToken", null) ?: ""
- Log.d("ServerApi", "ziplineUrl: $ziplineUrl")
- Log.d("ServerApi", "ziplineToken: $ziplineToken")
+ Log.d("ServerApi[init]", "ziplineUrl: $ziplineUrl")
+ Log.d("ServerApi[init]", "ziplineToken: $ziplineToken")
api = createRetrofit().create(ApiService::class.java)
}
suspend fun login(host: String, user: String, pass: String): String? {
- Log.d("login", "host: $host")
- Log.d("login", "user/pass: ${user}/${pass}")
+ Log.d("Api[login]", "host: $host")
+ Log.d("Api[login]", "user/pass: ${user}/${pass}")
return try {
val loginResponse = api.postLogin(LoginRequest(user, pass))
- Log.i("login", "loginResponse.code(): ${loginResponse.code()}")
+ Log.i("Api[login]", "loginResponse.code(): ${loginResponse.code()}")
if (loginResponse.isSuccessful) {
val tokenResponse = api.getToken()
val cookies = cookieJar.loadForRequest(host.toHttpUrl())
val cookieManager = CookieManager.getInstance()
for (cookie in cookies) {
- Log.d("login", "setCookie: $cookie")
+ Log.d("Api[login]", "setCookie: $cookie")
//cookieManager.setCookie(host, cookie.toString())
cookieManager.setCookie(host, cookie.toString()) {
cookieManager.flush()
@@ -69,46 +69,56 @@ class ZiplineApi(private val context: Context, url: String? = null) {
tokenResponse.token
} else {
//loginResponse.errorBody()?.string()?.take(200)?.let {
- // Log.i("login", "errorBody: $it")
+ // Log.i("Api[login]", "errorBody: $it")
//}
- Log.i("login", "errorBody: ${loginResponse.errorBody()?.string()?.take(255)}")
+ Log.i("Api[login]", "errorBody: ${loginResponse.errorBody()?.string()?.take(255)}")
null
}
} catch (e: Exception) {
- Log.e("login", "Exception: ${e.message}")
+ Log.e("Api[login]", "Exception: ${e.message}")
null
}
}
suspend fun shorten(url: String, vanity: String?): Response {
- Log.d("upload", "url: $url")
- Log.d("upload", "vanity: $vanity")
+ Log.d("Api[upload]", "url: $url")
+ Log.d("Api[upload]", "vanity: $vanity")
- val response = api.postShort(ziplineToken, ShortRequest(url, vanity, true))
+ val response = api.postShort(ShortRequest(url, vanity, true))
if (response.code() == 401) {
val token = reAuthenticate(api, ziplineUrl)
- Log.d("Api[upload]", "token: $token")
+ Log.d("Api[upload]", "reAuthenticate: token: $token")
if (token != null) {
- return api.postShort(token, ShortRequest(url, vanity, true))
+ return api.postShort(ShortRequest(url, vanity, true))
}
}
return response
}
- suspend fun upload(
- fileName: String,
- inputStream: InputStream,
- ): Response {
- Log.d("upload", "fileName: $fileName")
+ suspend fun upload(fileName: String, inputStream: InputStream): Response {
+ Log.d("Api[upload]", "fileName: $fileName")
val fileNameFormat = preferences.getString("file_name_format", null) ?: "random"
- Log.d("upload", "fileNameFormat: $fileNameFormat")
+ Log.d("Api[upload]", "fileNameFormat: $fileNameFormat")
val multiPart: MultipartBody.Part = inputStreamToMultipart(inputStream, fileName)
- val response = api.postUpload(ziplineToken, fileNameFormat.toString(), multiPart)
+ val response = api.postUpload(fileNameFormat.toString(), multiPart)
+ if (response.code() == 401) {
+ val token = reAuthenticate(api, ziplineUrl)
+ Log.d("Api[upload]", "reAuthenticate: token: $token")
+ if (token != null) {
+ return api.postUpload(fileNameFormat, multiPart)
+ }
+ }
+ return response
+ }
+
+ suspend fun stats(): Response {
+ Log.d("Api[stats]", "stats")
+ val response = api.getStats()
if (response.code() == 401) {
val token = reAuthenticate(api, ziplineUrl)
- Log.d("Api[upload]", "token: $token")
+ Log.d("Api[upload]", "reAuthenticate: token: $token")
if (token != null) {
- return api.postUpload(token, fileNameFormat, multiPart)
+ return api.getStats()
}
}
return response
@@ -137,7 +147,7 @@ class ZiplineApi(private val context: Context, url: String? = null) {
private suspend fun inputStreamToMultipart(
file: InputStream,
- fileName: String
+ fileName: String,
): MultipartBody.Part {
val contentType =
URLConnection.guessContentTypeFromName(fileName) ?: "application/octet-stream"
@@ -150,9 +160,19 @@ class ZiplineApi(private val context: Context, url: String? = null) {
private fun createRetrofit(): Retrofit {
val baseUrl = "${ziplineUrl}/api/"
Log.d("createRetrofit", "baseUrl: $baseUrl")
+ val versionName = context.packageManager.getPackageInfo(context.packageName, 0).versionName
+ val userAgent = "${context.getString(R.string.app_name)}/${versionName}"
+ Log.d("createRetrofit", "versionName: $versionName")
cookieJar = SimpleCookieJar()
client = OkHttpClient.Builder()
.cookieJar(cookieJar)
+ .addInterceptor { chain ->
+ val request = chain.request().newBuilder()
+ .header("User-Agent", userAgent)
+ .header("authorization", ziplineToken)
+ .build()
+ chain.proceed(request)
+ }
.build()
return Retrofit.Builder()
.baseUrl(baseUrl)
@@ -168,17 +188,18 @@ class ZiplineApi(private val context: Context, url: String? = null) {
@GET("user/token")
suspend fun getToken(): TokenResponse
+ @GET("user/stats")
+ suspend fun getStats(): Response
+
@Multipart
@POST("upload")
suspend fun postUpload(
- @Header("authorization") token: String,
@Header("x-zipline-format") format: String,
@Part file: MultipartBody.Part,
): Response
@POST("user/urls")
suspend fun postShort(
- @Header("authorization") token: String,
@Body request: ShortRequest,
): Response
}
@@ -222,6 +243,18 @@ class ZiplineApi(private val context: Context, url: String? = null) {
val url: String
)
+ data class StatsResponse(
+ @SerializedName("filesUploaded") val filesUploaded: Int,
+ @SerializedName("favoriteFiles") val favoriteFiles: Int,
+ @SerializedName("views") val views: Int,
+ @SerializedName("avgViews") val avgViews: Double,
+ @SerializedName("storageUsed") val storageUsed: Long,
+ @SerializedName("avgStorageUsed") val avgStorageUsed: Double,
+ @SerializedName("urlsCreated") val urlsCreated: Int,
+ @SerializedName("urlViews") val urlViews: Int,
+ )
+
+
inner class SimpleCookieJar : CookieJar {
private val cookieStore = mutableMapOf>()
@@ -244,6 +277,7 @@ class ZiplineApi(private val context: Context, url: String? = null) {
}
}
+
//data class LoginResponse(
// val user: TokenUser
//)
diff --git a/app/src/main/java/org/cssnr/zipline/db/ServerDao.kt b/app/src/main/java/org/cssnr/zipline/db/ServerDao.kt
new file mode 100644
index 0000000..1647b31
--- /dev/null
+++ b/app/src/main/java/org/cssnr/zipline/db/ServerDao.kt
@@ -0,0 +1,71 @@
+package org.cssnr.zipline.db
+
+import android.content.Context
+import androidx.room.Dao
+import androidx.room.Database
+import androidx.room.Delete
+import androidx.room.Entity
+import androidx.room.PrimaryKey
+import androidx.room.Query
+import androidx.room.Room
+import androidx.room.RoomDatabase
+import androidx.room.Upsert
+
+@Dao
+interface ServerDao {
+ @Upsert
+ fun upsert(stats: ServerEntity)
+
+ @Query("SELECT * FROM servers WHERE url = :url LIMIT 1")
+ fun get(url: String): ServerEntity?
+
+ @Query("SELECT * FROM servers ORDER BY ROWID")
+ fun getAll(): List
+
+ @Query("UPDATE servers SET token = :token WHERE url = :url")
+ fun setToken(url: String, token: String)
+
+ @Query("UPDATE servers SET active = 1 WHERE url = :url")
+ fun activate(url: String)
+
+ @Delete
+ fun delete(server: ServerEntity)
+}
+
+
+@Entity(tableName = "servers")
+data class ServerEntity(
+ @PrimaryKey val url: String,
+ val token: String = "",
+ val active: Boolean = false,
+ val filesUploaded: Int? = null,
+ val favoriteFiles: Int? = null,
+ val views: Int? = null,
+ val avgViews: Double? = null,
+ val storageUsed: Long? = null,
+ val avgStorageUsed: Double? = null,
+ val urlsCreated: Int? = null,
+ val urlViews: Int,
+)
+
+
+@Database(entities = [ServerEntity::class], version = 1)
+abstract class ServerDatabase : RoomDatabase() {
+ abstract fun serverDao(): ServerDao
+
+ companion object {
+ @Volatile
+ private var instance: ServerDatabase? = null
+
+ fun getInstance(context: Context): ServerDatabase =
+ instance ?: synchronized(this) {
+ instance ?: Room.databaseBuilder(
+ context.applicationContext,
+ ServerDatabase::class.java,
+ "server-database"
+ )
+ .fallbackToDestructiveMigration(true)
+ .build().also { instance = it }
+ }
+ }
+}
diff --git a/app/src/main/java/org/cssnr/zipline/ui/settings/SettingsFragment.kt b/app/src/main/java/org/cssnr/zipline/ui/settings/SettingsFragment.kt
index 9cf0b82..a08aab5 100644
--- a/app/src/main/java/org/cssnr/zipline/ui/settings/SettingsFragment.kt
+++ b/app/src/main/java/org/cssnr/zipline/ui/settings/SettingsFragment.kt
@@ -16,10 +16,14 @@ import android.widget.TextView
import android.widget.Toast
import androidx.appcompat.app.AlertDialog
import androidx.lifecycle.lifecycleScope
+import androidx.navigation.fragment.findNavController
import androidx.preference.ListPreference
import androidx.preference.Preference
import androidx.preference.PreferenceFragmentCompat
import androidx.preference.SwitchPreferenceCompat
+import androidx.work.ExistingPeriodicWorkPolicy
+import androidx.work.PeriodicWorkRequestBuilder
+import androidx.work.WorkManager
import com.google.android.material.color.MaterialColors
import com.google.android.material.dialog.MaterialAlertDialogBuilder
import com.google.firebase.analytics.ktx.analytics
@@ -29,6 +33,9 @@ import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import org.cssnr.zipline.R
import org.cssnr.zipline.api.FeedbackApi
+import org.cssnr.zipline.work.APP_WORKER_CONSTRAINTS
+import org.cssnr.zipline.work.AppWorker
+import java.util.concurrent.TimeUnit
class SettingsFragment : PreferenceFragmentCompat() {
@@ -58,6 +65,21 @@ class SettingsFragment : PreferenceFragmentCompat() {
val launcherAction = findPreference("launcher_action")
launcherAction?.summaryProvider = ListPreference.SimpleSummaryProvider.getInstance()
+ // Widget Settings
+ findPreference("open_widget_settings")?.setOnPreferenceClickListener {
+ Log.d("open_widget_settings", "setOnPreferenceClickListener")
+ findNavController().navigate(R.id.nav_action_settings_widget)
+ false
+ }
+
+ // Update Interval
+ val workInterval = findPreference("work_interval")
+ workInterval?.summaryProvider = ListPreference.SimpleSummaryProvider.getInstance()
+ workInterval?.setOnPreferenceChangeListener { _, newValue ->
+ Log.d("work_interval", "newValue: $newValue")
+ ctx.updateWorkManager(workInterval, newValue)
+ }
+
// Toggle Analytics
val analyticsEnabled = findPreference("analytics_enabled")
analyticsEnabled?.setOnPreferenceChangeListener { _, newValue ->
@@ -104,6 +126,40 @@ class SettingsFragment : PreferenceFragmentCompat() {
//Log.d("SettingsFragment", "enableBiometrics: $enableBiometrics")
}
+ fun Context.updateWorkManager(listPref: ListPreference, newValue: Any): Boolean {
+ Log.d("updateWorkManager", "listPref: ${listPref.value} - newValue: $newValue")
+ val value = newValue as? String
+ Log.d("updateWorkManager", "String value: $value")
+ if (value.isNullOrEmpty()) {
+ Log.w("updateWorkManager", "NULL OR EMPTY - false")
+ return false
+ } else if (listPref.value == value) {
+ Log.i("updateWorkManager", "NO CHANGE - false")
+ return false
+ } else {
+ Log.i("updateWorkManager", "RESCHEDULING WORK - true")
+ val interval = value.toLongOrNull()
+ Log.i("updateWorkManager", "interval: $interval")
+ if (interval == null || interval == 0L) {
+ Log.i("updateWorkManager", "DISABLING WORK")
+ WorkManager.getInstance(this).cancelUniqueWork("app_worker")
+ return true
+ } else {
+ val newRequest =
+ PeriodicWorkRequestBuilder(interval, TimeUnit.MINUTES)
+ .setInitialDelay(interval, TimeUnit.MINUTES)
+ .setConstraints(APP_WORKER_CONSTRAINTS)
+ .build()
+ WorkManager.getInstance(this).enqueueUniquePeriodicWork(
+ "app_worker",
+ ExistingPeriodicWorkPolicy.REPLACE,
+ newRequest
+ )
+ return true
+ }
+ }
+ }
+
fun Context.toggleAnalytics(switchPreference: SwitchPreferenceCompat, newValue: Any) {
Log.d("toggleAnalytics", "newValue: $newValue")
if (newValue as Boolean) {
diff --git a/app/src/main/java/org/cssnr/zipline/ui/settings/WidgetSettingsFragment.kt b/app/src/main/java/org/cssnr/zipline/ui/settings/WidgetSettingsFragment.kt
new file mode 100644
index 0000000..9985f6c
--- /dev/null
+++ b/app/src/main/java/org/cssnr/zipline/ui/settings/WidgetSettingsFragment.kt
@@ -0,0 +1,42 @@
+package org.cssnr.zipline.ui.settings
+
+import android.os.Bundle
+import android.util.Log
+import androidx.preference.ListPreference
+import androidx.preference.PreferenceFragmentCompat
+import androidx.preference.SeekBarPreference
+import org.cssnr.zipline.R
+
+class WidgetSettingsFragment : PreferenceFragmentCompat() {
+
+ override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) {
+ Log.d("WidgetSettingsFragment", "rootKey: $rootKey")
+ setPreferencesFromResource(R.xml.preferences_widget, rootKey)
+
+ // Text Color
+ val textColor = findPreference("widget_text_color")
+ Log.d("WidgetSettingsFragment", "textColor: $textColor")
+ textColor?.summaryProvider = ListPreference.SimpleSummaryProvider.getInstance()
+
+ // BG Color
+ val bgColor = findPreference("widget_bg_color")
+ Log.d("WidgetSettingsFragment", "bgColor: $bgColor")
+ bgColor?.summaryProvider = ListPreference.SimpleSummaryProvider.getInstance()
+
+ // BG Opacity
+ val bgOpacity = preferenceManager.sharedPreferences?.getInt("widget_bg_opacity", 25)
+ Log.d("WidgetSettingsFragment", "bgOpacity: $bgOpacity")
+ val seekBar = findPreference("widget_bg_opacity")
+ seekBar?.summary = "Current Value: $bgOpacity"
+ seekBar?.apply {
+ setOnPreferenceChangeListener { pref, newValue ->
+ val intValue = (newValue as Int)
+ var stepped = ((intValue + 2) / 5) * 5
+ Log.d("WidgetSettingsFragment", "stepped: $stepped")
+ value = stepped
+ pref.summary = "Current Value: $stepped"
+ false
+ }
+ }
+ }
+}
diff --git a/app/src/main/java/org/cssnr/zipline/ui/upload/TextFragment.kt b/app/src/main/java/org/cssnr/zipline/ui/upload/TextFragment.kt
index 7361c3b..97036ef 100644
--- a/app/src/main/java/org/cssnr/zipline/ui/upload/TextFragment.kt
+++ b/app/src/main/java/org/cssnr/zipline/ui/upload/TextFragment.kt
@@ -55,7 +55,7 @@ class TextFragment : Fragment() {
navController = findNavController()
val extraText = arguments?.getString("text")?.trim() ?: ""
- Log.d("Text[onViewCreated]", "extraText: $extraText")
+ Log.d("Text[onViewCreated]", "extraText: ${extraText.take(100)}")
if (extraText.isEmpty()) {
// TODO: Better Handle this Error
diff --git a/app/src/main/java/org/cssnr/zipline/widget/WidgetConfiguration.kt b/app/src/main/java/org/cssnr/zipline/widget/WidgetConfiguration.kt
new file mode 100644
index 0000000..5cc7ddf
--- /dev/null
+++ b/app/src/main/java/org/cssnr/zipline/widget/WidgetConfiguration.kt
@@ -0,0 +1,127 @@
+package org.cssnr.zipline.widget
+
+import android.app.Activity
+import android.appwidget.AppWidgetManager
+import android.content.Intent
+import android.os.Bundle
+import android.util.Log
+import android.widget.Button
+import android.widget.RadioGroup
+import android.widget.SeekBar
+import android.widget.TextView
+import androidx.core.content.edit
+import androidx.preference.PreferenceManager
+import org.cssnr.zipline.R
+
+class WidgetConfiguration : Activity() {
+
+ private var appWidgetId = AppWidgetManager.INVALID_APPWIDGET_ID
+
+ override fun onCreate(savedInstanceState: Bundle?) {
+ super.onCreate(savedInstanceState)
+ setContentView(R.layout.widget_configure)
+
+ setResult(RESULT_CANCELED)
+
+ val intent = intent
+ val extras = intent.extras
+ if (extras != null) {
+ appWidgetId = extras.getInt(
+ AppWidgetManager.EXTRA_APPWIDGET_ID,
+ AppWidgetManager.INVALID_APPWIDGET_ID
+ )
+ }
+ if (appWidgetId == AppWidgetManager.INVALID_APPWIDGET_ID) finish()
+
+ val preferences = PreferenceManager.getDefaultSharedPreferences(applicationContext)
+ val bgColor = preferences.getString("widget_bg_color", null) ?: "black"
+ Log.i("WidgetConfiguration", "bgColor: $bgColor")
+ val textColor = preferences.getString("widget_text_color", null) ?: "white"
+ Log.i("WidgetConfiguration", "textColor: $textColor")
+ val bgOpacity = preferences.getInt("widget_bg_opacity", 35)
+ Log.i("WidgetConfiguration", "bgOpacity: $bgOpacity")
+
+ val bgOpacityText = findViewById(R.id.bg_opacity_percent)
+ bgOpacityText.text = getString(R.string.background_opacity, bgOpacity)
+
+ val bgColorId = mapOf(
+ "white" to R.id.option_white,
+ "black" to R.id.option_black,
+ "liberty" to R.id.option_liberty,
+ )
+ val textColorId = mapOf(
+ "white" to R.id.text_white,
+ "black" to R.id.text_black,
+ "liberty" to R.id.text_liberty,
+ )
+
+ val backgroundOptions = findViewById(R.id.background_options)
+ val bgSelected = bgColorId[bgColor]
+ if (bgSelected != null) backgroundOptions.check(bgSelected)
+ val textOptions = findViewById(R.id.text_options)
+ val textSelected = textColorId[textColor]
+ if (textSelected != null) textOptions.check(textSelected)
+
+ val seekBar = findViewById(R.id.opacity_percent)
+ seekBar.progress = bgOpacity
+ seekBar.setOnSeekBarChangeListener(object : SeekBar.OnSeekBarChangeListener {
+ override fun onProgressChanged(seekBar: SeekBar?, progress: Int, fromUser: Boolean) {
+ if (fromUser && seekBar != null) {
+ val stepped = ((progress + 2) / 5) * 5
+ seekBar.progress = stepped
+ bgOpacityText.text = getString(R.string.background_opacity, stepped)
+ Log.d("onProgressChanged", "stepped: $stepped")
+ }
+ }
+
+ override fun onStartTrackingTouch(seekBar: SeekBar?) {
+ Log.d("onProgressChanged", "START")
+ }
+
+ override fun onStopTrackingTouch(seekBar: SeekBar?) {
+ Log.d("onProgressChanged", "STOP")
+ }
+ })
+
+ val confirmButton = findViewById