diff --git a/.gitignore b/.gitignore
index 2061b95805730227d4356fd92ce694b54d49c486..abb3b766c76711c7a0d022675b7a23bb59ba42eb 100644
--- a/.gitignore
+++ b/.gitignore
@@ -7,6 +7,8 @@
/.idea/workspace.xml
/.idea/navEditor.xml
/.idea/assetWizardSettings.xml
+/.idea/other.xml
+/.idea/deploymentTargetSelector.xml
.DS_Store
/build
/captures
@@ -14,8 +16,10 @@
.cxx
local.properties
app/release/
-app/full/release/
-app/simple/release/
+app/prTesting/release/
+app/issueTesting/release/
+app/preview/release/
+app/stable/release/
*.log
/.idea/deploymentTargetDropDown.xml
*.trace
diff --git a/.idea/appInsightsSettings.xml b/.idea/appInsightsSettings.xml
new file mode 100644
index 0000000000000000000000000000000000000000..371f2e299f27ed45ecbf54b02a2cda9467d794d4
--- /dev/null
+++ b/.idea/appInsightsSettings.xml
@@ -0,0 +1,26 @@
+
+
+
+
+
+
\ No newline at end of file
diff --git a/.idea/copyright/GugalProfile.xml b/.idea/copyright/GugalProfile.xml
index 2aa2f4a70f8444887262c58f4def5758ada34b27..059172ea4b00b093a896377cbd42833542a04a4c 100644
--- a/.idea/copyright/GugalProfile.xml
+++ b/.idea/copyright/GugalProfile.xml
@@ -1,6 +1,6 @@
-
+
\ No newline at end of file
diff --git a/.idea/inspectionProfiles/Project_Default.xml b/.idea/inspectionProfiles/Project_Default.xml
index 44ca2d9b03443dff942748798643a4f05eec7206..e0da3ee56225b6261bce5890a1f23c0796cef46a 100644
--- a/.idea/inspectionProfiles/Project_Default.xml
+++ b/.idea/inspectionProfiles/Project_Default.xml
@@ -1,6 +1,34 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
@@ -9,6 +37,10 @@
+
+
+
+
diff --git a/.idea/kotlinc.xml b/.idea/kotlinc.xml
index 0fc3113136756acc4597486432227a66d5ebe736..bb4493707fa33f413148da60b12e35a37a704010 100644
--- a/.idea/kotlinc.xml
+++ b/.idea/kotlinc.xml
@@ -1,6 +1,6 @@
-
+
\ No newline at end of file
diff --git a/.idea/migrations.xml b/.idea/migrations.xml
new file mode 100644
index 0000000000000000000000000000000000000000..f8051a6f973e69a86e6f07f1a1c87f17a31c7235
--- /dev/null
+++ b/.idea/migrations.xml
@@ -0,0 +1,10 @@
+
+
+
+
+
+
\ No newline at end of file
diff --git a/.idea/misc.xml b/.idea/misc.xml
index ae61f5a66ee267ce6e089bf4035037f984306f91..7e7e05a62184b3ae06c862f335ac5cb724bd3269 100644
--- a/.idea/misc.xml
+++ b/.idea/misc.xml
@@ -1,4 +1,3 @@
-
diff --git a/.idea/runConfigurations.xml b/.idea/runConfigurations.xml
new file mode 100644
index 0000000000000000000000000000000000000000..16660f1d80a1f5cde389ecce783051eb8f68223d
--- /dev/null
+++ b/.idea/runConfigurations.xml
@@ -0,0 +1,17 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/.idea/runConfigurations/docs.xml b/.idea/runConfigurations/docs.xml
new file mode 100644
index 0000000000000000000000000000000000000000..b4c09fddd1d13c942ef4a9d52677bac492f64e06
--- /dev/null
+++ b/.idea/runConfigurations/docs.xml
@@ -0,0 +1,24 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ true
+ true
+ false
+ false
+
+
+
\ No newline at end of file
diff --git a/app/build.gradle b/app/build.gradle
index ed1757b82232897c0cc862e7611435e863fb3917..fe221c7cbdba1520e18e50763ebcf829b5d1b09a 100644
--- a/app/build.gradle
+++ b/app/build.gradle
@@ -2,6 +2,7 @@ plugins {
id 'com.android.application'
id 'kotlin-android'
id 'org.jetbrains.dokka'
+ id 'org.jetbrains.kotlin.plugin.compose'
}
import org.jetbrains.dokka.gradle.DokkaTask
@@ -42,14 +43,19 @@ tasks.withType(DokkaTask.class).configureEach {
matchingRegex.set("com.porg.gugal.providers.*")
suppress.set(false)
}
- // the M3 package
+ // the data.results package (contains classes used in serp providers)
perPackageOption {
- matchingRegex.set("com.porg.m3.*")
+ matchingRegex.set("com.porg.gugal.data.results.*")
+ suppress.set(false)
+ }
+ // the BuildInfo class (it's cleared for serp providers)
+ perPackageOption {
+ matchingRegex.set("com.porg.gugal.BuildInfo")
suppress.set(false)
}
- // the data package (contains classes used in serp providers)
+ // the M3 package
perPackageOption {
- matchingRegex.set("com.porg.gugal.data.*")
+ matchingRegex.set("com.porg.m3.*")
suppress.set(false)
}
@@ -62,6 +68,15 @@ tasks.withType(DokkaTask.class).configureEach {
matchingRegex.set("com.porg.gugal.providers.searx.*")
suppress.set(true)
}
+ perPackageOption {
+ matchingRegex.set("com.porg.gugal.providers.stract.*")
+ suppress.set(true)
+ }
+ // skip the M3 samples
+ perPackageOption {
+ matchingRegex.set("com.porg.m3.Samples")
+ suppress.set(true)
+ }
// skip everything in gugal that isn't explicitly allowed
// (i.e. the app logic)
@@ -79,6 +94,26 @@ tasks.withType(DokkaTask.class).configureEach {
}
}
}
+
+
+def getGitHash = { ->
+ def stdout = new ByteArrayOutputStream()
+ exec {
+ commandLine 'git', 'rev-parse', '--short', 'HEAD'
+ standardOutput = stdout
+ }
+ return stdout.toString().trim()
+}
+
+def getGitBranch = { ->
+ def stdout = new ByteArrayOutputStream()
+ exec {
+ commandLine 'git', 'rev-parse', '--abbrev-ref', 'HEAD'
+ standardOutput = stdout
+ }
+ return stdout.toString().trim()
+}
+
android {
compileSdk 35
@@ -86,8 +121,8 @@ android {
applicationId "com.porg.gugal"
minSdk 24
targetSdk 35
- versionCode 25
- versionName "0.8.4"
+ versionCode 27
+ versionName "0.9.p2"
archivesBaseName = "Gugal-$versionName"
@@ -97,6 +132,10 @@ android {
}
}
+ buildFeatures {
+ buildConfig = true
+ }
+
buildTypes {
release {
minifyEnabled false
@@ -118,7 +157,7 @@ android {
}
composeOptions {
kotlinCompilerExtensionVersion "1.4.3"
- kotlinCompilerVersion '1.8.10'
+ kotlinCompilerVersion '2.0.0'
}
packagingOptions {
resources {
@@ -131,47 +170,56 @@ android {
// Disable including dependency metadata when building Android App Bundles
includeInBundle false
}
- flavorDimensions 'features'
+ flavorDimensions "stability"
productFlavors {
- full {
- dimension 'features'
+ stable {
+ dimension 'stability'
}
- simple {
- dimension 'features'
+ preview {
+ dimension 'stability'
+ }
+ issueTesting {
+ dimension 'stability'
+ versionNameSuffix "-if-${getGitBranch()}-${getGitHash()}"
+ }
+ prTesting {
+ dimension 'stability'
+ versionNameSuffix "-pr-${getGitBranch()}-${getGitHash()}"
+ }
+ currentNightly {
+ dimension 'stability'
+ versionNameSuffix "-night-${getGitBranch()}-${getGitHash()}"
}
- }
- buildFeatures {
- buildConfig = true
}
namespace 'com.porg.gugal'
}
dependencies {
- implementation platform('androidx.compose:compose-bom:2024.11.00')
+ implementation platform('androidx.compose:compose-bom:2025.04.00')
implementation 'androidx.compose.material:material'
implementation "androidx.compose.ui:ui"
implementation 'androidx.compose.material3:material3'
implementation "androidx.compose.ui:ui-tooling-preview"
implementation "androidx.compose.material3:material3-window-size-class"
- implementation 'androidx.core:core-ktx:1.15.0'
+ implementation 'androidx.core:core-ktx:1.16.0'
implementation 'androidx.appcompat:appcompat:1.7.0'
implementation 'com.google.android.material:material:1.12.0'
implementation 'androidx.lifecycle:lifecycle-runtime-ktx:2.8.7'
- implementation 'androidx.activity:activity-compose:1.9.3'
+ implementation 'androidx.activity:activity-compose:1.10.1'
implementation 'com.android.volley:volley:1.2.1'
- implementation 'androidx.security:security-crypto-ktx:1.1.0-alpha06'
- implementation 'androidx.navigation:navigation-compose:2.8.4'
- implementation "org.jetbrains.kotlin:kotlin-reflect:1.9.23"
- implementation 'com.google.code.gson:gson:2.10.1'
+ implementation 'androidx.security:security-crypto-ktx:1.1.0-alpha07'
+ implementation 'androidx.navigation:navigation-compose:2.8.9'
+ implementation "org.jetbrains.kotlin:kotlin-reflect:2.0.21"
+ implementation 'com.google.code.gson:gson:2.12.1'
implementation 'androidx.browser:browser:1.8.0'
implementation 'com.rometools:rome:2.1.0'
- implementation 'com.gitlab.cat-aspect:m3x:1.0-beta3'
+ implementation 'com.gitlab.cat-aspect:m3x:1.0-beta4'
debugImplementation "androidx.compose.ui:ui-tooling"
testImplementation 'junit:junit:4.13.2'
- androidTestImplementation platform('androidx.compose:compose-bom:2024.11.00')
+ androidTestImplementation platform('androidx.compose:compose-bom:2025.04.00')
androidTestImplementation 'androidx.test.ext:junit:1.2.1'
androidTestImplementation 'androidx.test.espresso:espresso-core:3.6.1'
androidTestImplementation "androidx.compose.ui:ui-test-junit4"
diff --git a/app/extra_docs.md b/app/extra_docs.md
index 16a542c1ce4ec7419e5f948792eea5ab0f57350f..5bfb7db6b254ca6266691adfe50c137d8103cf00 100644
--- a/app/extra_docs.md
+++ b/app/extra_docs.md
@@ -2,15 +2,6 @@
Welcome to the SERP provider API reference.
-# Package com.porg.m3
-
-Material 3-style components that aren't included in Google's official
-library, designed to be consistent with those components.
-
-# Package com.porg.m3.settings
-
-Material 3-style components to be used in settings pages.
-
# Package com.porg.gugal.data
Various data classes shared by SERP providers, like results.
@@ -22,8 +13,16 @@ SERP provider interface itself.
# Package com.porg.gugal.providers.exceptions
-Deprecated in favor of error responses (see `com.porg.gugal.providers.responses`).
+Can be used to tell Gugal (and the user) that something has gone wrong when starting a search.
# Package com.porg.gugal.providers.responses
-Can be used to tell Gugal (and the user) that something has gone wrong.
\ No newline at end of file
+Can be used to tell Gugal (and the user) that something has gone wrong during a search.
+
+# Package com.porg.gugal.providers.experimental
+
+Experimental APIs related to SERP providers. Here be dragons!
+
+# Package com.porg.m3
+
+Extra Material 3 components. **Deprecated** in favor of [M3X](https://gitlab.com/cat-aspect/m3x).
\ No newline at end of file
diff --git a/app/src/androidTest/java/com/porg/gugal/Words.kt b/app/src/androidTest/java/com/porg/gugal/Words.kt
index 5d0ca09b54e3b71b5e90b524fa7653f33b4088df..50df687ab39e175ef7bd4e449cd5b3708dec1516 100644
--- a/app/src/androidTest/java/com/porg/gugal/Words.kt
+++ b/app/src/androidTest/java/com/porg/gugal/Words.kt
@@ -1,7 +1,7 @@
/*
* Words.kt
* Gugal
- * Copyright (c) 2023 thegreatporg
+ * Copyright (c) 2023-2025 the Gugal maintainers
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
diff --git a/app/src/androidTest/java/com/porg/gugal/ui/ResultsPageTest.kt b/app/src/androidTest/java/com/porg/gugal/ui/ResultsPageTest.kt
index 6f6a196a8b23d11d24880188df6e4640a0fb761f..084de2a9983c9b0d65e48e656a81282dc3b344a1 100644
--- a/app/src/androidTest/java/com/porg/gugal/ui/ResultsPageTest.kt
+++ b/app/src/androidTest/java/com/porg/gugal/ui/ResultsPageTest.kt
@@ -1,7 +1,7 @@
/*
* ResultsPageTest.kt
* Gugal
- * Copyright (c) 2023 thegreatporg
+ * Copyright (c) 2023-2025 the Gugal maintainers
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
@@ -25,7 +25,6 @@ import androidx.compose.material.ExperimentalMaterialApi
import androidx.compose.ui.test.*
import androidx.compose.ui.test.junit4.createAndroidComposeRule
import com.porg.gugal.Global
-import com.porg.gugal.MainActivity
import com.porg.gugal.Words.Companion.listOfWords
import com.porg.gugal.providers.DummySerp
import org.junit.Rule
diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index ed109966d5d0d89092e1fbdc1b9239f429428be6..928b7b8b0ef523ead8487f13dca374739e60d68c 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -1,13 +1,16 @@
+ xmlns:tools="http://schemas.android.com/tools">
+
+
+
@@ -39,10 +42,6 @@
-
+
@@ -96,7 +99,20 @@
android:theme="@style/Theme.Gugal.NoActionBar" />
+
+
+
+
@@ -105,6 +121,11 @@
+
+
+
+
+
@@ -118,15 +139,11 @@
-
-
+
+
-
-
+
+
@@ -134,15 +151,11 @@
-
-
+
+
-
-
+
+
@@ -150,15 +163,11 @@
-
-
+
+
-
-
+
+
diff --git a/app/src/main/java/com/porg/gugal/BuildInfo.kt b/app/src/main/java/com/porg/gugal/BuildInfo.kt
new file mode 100644
index 0000000000000000000000000000000000000000..0e7aae3e5eef01dda4d1ef510eb70eb1c5ee68f4
--- /dev/null
+++ b/app/src/main/java/com/porg/gugal/BuildInfo.kt
@@ -0,0 +1,47 @@
+/*
+ * BuildInfo.kt
+ * Gugal
+ * Copyright (c) 2024-2025 the Gugal maintainers
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ */
+
+package com.porg.gugal
+
+class BuildInfo {
+ companion object {
+ /**
+ * Is this build a preview version?
+ *
+ * Preview versions are early versions of the next version of Gugal, released for
+ * developers and enthusiasts.
+ */
+ fun isPreview(): Boolean {
+ return BuildConfig.VERSION_NAME.contains(".p")
+ }
+
+ /**
+ * Is this build a testing build?
+ *
+ * Testing builds are made to test changes like issue fixes and merge requests, released
+ * in discussion threads of said issues and merge requests
+ */
+ fun isTest(): Boolean {
+ return BuildConfig.VERSION_NAME.contains("pr") ||
+ BuildConfig.VERSION_NAME.contains("ci") ||
+ BuildConfig.VERSION_NAME.contains("if") ||
+ BuildConfig.VERSION_NAME.contains("night")
+ }
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/com/porg/gugal/DemoMode.kt b/app/src/main/java/com/porg/gugal/DemoMode.kt
new file mode 100644
index 0000000000000000000000000000000000000000..d1ec3b3ca2674e32927a4143bf289bf043e6a0ea
--- /dev/null
+++ b/app/src/main/java/com/porg/gugal/DemoMode.kt
@@ -0,0 +1,96 @@
+/*
+ * DemoMode.kt
+ * Gugal
+ * Copyright © 2025 the Gugal maintainers
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ */
+
+package com.porg.gugal
+
+import com.porg.gugal.news.Article
+import com.porg.gugal.news.Feed
+import java.util.Date
+
+/**
+ * Resources for demo mode.
+ */
+class DemoMode {
+ companion object {
+ /**
+ * Demo search history.
+ */
+ val queries = arrayOf(
+ "Gugal",
+ "open-source software",
+ "Android privacy",
+ "libre software",
+ "degoogling"
+ )
+
+ /**
+ * Demo media for the News setup page.
+ */
+ val editorial = listOf(
+ Feed("https://www.libre.bee/rss/feed.xml", "The Libre Bee"),
+ Feed("https://www.aday.later/feed.xml", "A Day Later"),
+ Feed("https://androidgovt.phone/feed/atom", "Android Government"),
+ Feed("https://www.news.room/rss/world.xml", "The Newsroom - World News"),
+ Feed("https://www.news.room/rss/pol.xml", "The Newsroom - Politics")
+ )
+
+ /**
+ * Demo articles.
+ */
+ val articles = listOf(
+ Article(
+ "The next Android version is here",
+ "With even more 'helpful' generative AI features and less useful features, this version is sure to be a hit.",
+ "https://news.example.com/stories/2023/04/10/android",
+ "Jade Smith",
+ mutableListOf(),
+ Date(2023, 4, 10),
+ "This week, we came across Gugal. This app describes itself as '[a] clean, lightweight FOSS search app."
+ ),
+ Article(
+ "How to reduce your digital footprint",
+ "Tech giants are trying to destroy your privacy - let's prevent that as much as we can with these open-source, free apps.",
+ "https://news.example.com/stories/2023/04/10/android",
+ "Adam Butcher (The Libre Bee)",
+ mutableListOf(),
+ Date(2023, 4, 10),
+ "This week, we came across Gugal. This app describes itself as '[a] clean, lightweight FOSS search app."
+ ),
+ Article(
+ "Gugal - an open source search client",
+ "Reviewing an open source app that helps you google without the Google app.",
+ "https://news.example.com/stories/2023/01/01/gugal",
+ "John Smith (The Libre Bee)",
+ mutableListOf(),
+ Date(2023, 4, 9),
+ "This week, we came across Gugal. This app describes itself as '[a] clean, lightweight FOSS search app."
+ ),
+ Article(
+ "Global Cooperation Leads to Progress on Key Development Goals - The Newsroom",
+ "Despite ongoing challenges, the global community made significant strides in addressing key development goals over the past year. From reducing poverty and hunger to improving education...\n" +
+ "(read the rest of the article online)",
+ "https://news.example.com/stories/2023/04/09/development",
+ "Arthur I.",
+ mutableListOf(),
+ Date(2023, 4, 9),
+ "This week, we came across Gugal. This app describes itself as '[a] clean, lightweight FOSS search app."
+ )
+ )
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/com/porg/gugal/Global.kt b/app/src/main/java/com/porg/gugal/Global.kt
index ddab706fd09b157e38a98ddedcee1af45c60b3e7..2092e5257dce86ba6e058ca9a1edfbb7a247d2cc 100644
--- a/app/src/main/java/com/porg/gugal/Global.kt
+++ b/app/src/main/java/com/porg/gugal/Global.kt
@@ -1,7 +1,7 @@
/*
* Global.kt
* Gugal
- * Copyright (c) 2022 thegreatporg
+ * Copyright (c) 2022-2025 the Gugal maintainers
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
@@ -25,13 +25,17 @@ import android.net.Uri
import androidx.browser.customtabs.CustomTabsIntent
import androidx.security.crypto.EncryptedSharedPreferences
import androidx.security.crypto.MasterKeys
+import com.porg.gugal.data.container.Experiment
import com.porg.gugal.devopt.providers.AlwaysFailingProvider
+import com.porg.gugal.devopt.providers.ResultDataTestProvider
+import com.porg.gugal.providers.DummySerp
import com.porg.gugal.news.Article
import com.porg.gugal.news.Feed
import com.porg.gugal.providers.SerpProvider
import com.porg.gugal.providers.cse.GoogleCseSerp
import com.porg.gugal.providers.searx.SearXSerp
-import java.util.Date
+import com.porg.gugal.providers.stract.StractSerp
+import androidx.core.net.toUri
class Global {
companion object {
@@ -41,15 +45,20 @@ class Global {
// TODO if adding SERP providers, add your provider in the function and array below
val allSerpProviders: Array = arrayOf(
GoogleCseSerp.id,
- SearXSerp.id
+ SearXSerp.id,
+ StractSerp.id
)
fun setSerpProvider(serpID: String) {
when (serpID) {
GoogleCseSerp.id -> serpProvider = GoogleCseSerp()
SearXSerp.id -> serpProvider = SearXSerp()
+ StractSerp.id -> serpProvider = StractSerp()
// Check if serpID matches your SERP provider's ID, and if so set Global.serpProvider to
// an instance of your SERP provider.
AlwaysFailingProvider.id -> serpProvider = AlwaysFailingProvider()
+ DummySerp.id -> serpProvider = DummySerp()
+ ResultDataTestProvider.id -> serpProvider = ResultDataTestProvider()
+ else -> throw IllegalArgumentException("Unknown SERP provider: $serpID")
}
}
@@ -70,9 +79,45 @@ class Global {
// Experiments
var gugalNews = Experiment("News", false)
+
+ @Deprecated(
+ "Experiments are now in the app's container.",
+ ReplaceWith(
+ "(application as GugalApplication).container.experiments[Experiment.ID_CUSTOM_TABS]",
+ "com.porg.gugal.data.GugalApplication",
+ "com.porg.gugal.data.container.Experiment"
+ )
+ )
var custTabInResults = Experiment("Custom tabs for results", false)
+
+ @Deprecated(
+ "Experiments are now in the app's container.",
+ ReplaceWith(
+ "(application as GugalApplication).container.experiments[Experiment.ID_RESULT_GRID]",
+ "com.porg.gugal.data.GugalApplication",
+ "com.porg.gugal.data.container.Experiment"
+ )
+ )
var resultGrid = Experiment("Use grid for results (tablets only)", false)
+
+ @Deprecated(
+ "Experiments are now in the app's container.",
+ ReplaceWith(
+ "(application as GugalApplication).container.experiments[Experiment.ID_SEARCH_PAGES]",
+ "com.porg.gugal.data.GugalApplication",
+ "com.porg.gugal.data.container.Experiment"
+ )
+ )
var searchPages = Experiment("Search result pagination", false)
+
+ @Deprecated(
+ "Experiments are now in the app's container.",
+ ReplaceWith(
+ "(application as GugalApplication).container.experiments[Experiment.ID_NEWS_SETUP]",
+ "com.porg.gugal.data.GugalApplication",
+ "com.porg.gugal.data.container.Experiment"
+ )
+ )
var gugalNewsSetup = Experiment("News - Setup pages", false, gugalNews)
// Magic numbers
@@ -80,61 +125,8 @@ class Global {
const val THEME_LIGHT = 1
const val THEME_DARK = 2
- // Demo mode
- var demoMode = false
- val demoModeQueries = arrayOf(
- "Gugal",
- "open-source software",
- "Android privacy",
- "libre software",
- "degoogling"
- )
- val demoModeEditorial = listOf(
- Feed("https://www.libre.bee/rss/feed.xml","The Libre Bee"),
- Feed("https://www.aday.later/feed.xml","A Day Later"),
- Feed("https://androidgovt.phone/feed/atom","Android Government"),
- Feed("https://www.news.room/rss/world.xml","The Newsroom - World News"),
- Feed("https://www.news.room/rss/pol.xml","The Newsroom - Politics")
- )
- val demoModeArticles = listOf(
- Article(
- "The next Android version is here",
- "With even more 'helpful' generative AI features and less useful features, this version is sure to be a hit.",
- "https://news.example.com/stories/2023/04/10/android",
- "Jade Smith",
- mutableListOf(),
- Date(2023, 4, 10),
- "This week, we came across Gugal. This app describes itself as '[a] clean, lightweight FOSS search app."
- ),
- Article(
- "How to reduce your digital footprint",
- "Tech giants are trying to destroy your privacy - let's prevent that as much as we can with these open-source, free apps.",
- "https://news.example.com/stories/2023/04/10/android",
- "Adam Butcher (The Libre Bee)",
- mutableListOf(),
- Date(2023, 4, 10),
- "This week, we came across Gugal. This app describes itself as '[a] clean, lightweight FOSS search app."
- ),
- Article(
- "Gugal - an open source search client",
- "Reviewing an open source app that helps you google without the Google app.",
- "https://news.example.com/stories/2023/01/01/gugal",
- "John Smith (The Libre Bee)",
- mutableListOf(),
- Date(2023, 4, 9),
- "This week, we came across Gugal. This app describes itself as '[a] clean, lightweight FOSS search app."
- ),
- Article(
- "Global Cooperation Leads to Progress on Key Development Goals - The Newsroom",
- "Despite ongoing challenges, the global community made significant strides in addressing key development goals over the past year. From reducing poverty and hunger to improving education...\n" +
- "(read the rest of the article online)",
- "https://news.example.com/stories/2023/04/09/development",
- "Arthur I.",
- mutableListOf(),
- Date(2023, 4, 9),
- "This week, we came across Gugal. This app describes itself as '[a] clean, lightweight FOSS search app."
- )
- )
+ // Constants
+ const val BACKUP_VERSION = 2
// Tests
const val TEST_TAG_RP_QUERY_FIELD: String = "RP_QueryField"
@@ -153,5 +145,5 @@ fun open(context: Context, url: String) {
builder.setStartAnimations(context, android.R.anim.fade_in, android.R.anim.fade_out)
builder.setExitAnimations(context, android.R.anim.fade_in, android.R.anim.fade_out)
// launch the passed URL
- builder.build().launchUrl(context, Uri.parse(url))
+ builder.build().launchUrl(context, url.toUri())
}
\ No newline at end of file
diff --git a/app/src/main/java/com/porg/gugal/GugalApplication.kt b/app/src/main/java/com/porg/gugal/GugalApplication.kt
new file mode 100644
index 0000000000000000000000000000000000000000..f6a4fed60032b94494b52236fd2b2ade448f4ba4
--- /dev/null
+++ b/app/src/main/java/com/porg/gugal/GugalApplication.kt
@@ -0,0 +1,34 @@
+/*
+ * GugalApplication.kt
+ * Gugal
+ * Copyright (c) 2024-2025 the Gugal maintainers
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ */
+
+package com.porg.gugal
+
+import android.app.Application
+import com.porg.gugal.data.container.AppContainer
+import com.porg.gugal.data.container.DefaultAppContainer
+
+class GugalApplication: Application() {
+
+ lateinit var container: AppContainer
+
+ override fun onCreate() {
+ super.onCreate()
+ container = DefaultAppContainer()
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/com/porg/gugal/SearchBoxProvider.kt b/app/src/main/java/com/porg/gugal/SearchBoxProvider.kt
index 3db8e339e263d0fe593f0432def20342a596d792..47a670174dec0d3773472f9ed1a4ba0df91f5290 100644
--- a/app/src/main/java/com/porg/gugal/SearchBoxProvider.kt
+++ b/app/src/main/java/com/porg/gugal/SearchBoxProvider.kt
@@ -1,7 +1,7 @@
/*
* SearchBoxProvider.kt
* Gugal
- * Copyright (c) 2021 thegreatporg
+ * Copyright (c) 2021-2025 the Gugal maintainers
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
@@ -17,7 +17,6 @@
* along with this program. If not, see .
*/
-
package com.porg.gugal
import android.app.PendingIntent
@@ -28,6 +27,7 @@ import android.content.Intent
import android.widget.RemoteViews
import androidx.compose.animation.ExperimentalAnimationApi
import androidx.compose.material.ExperimentalMaterialApi
+import com.porg.gugal.ui.MainActivity
@ExperimentalMaterialApi
class SearchBoxProvider : AppWidgetProvider() {
@@ -41,11 +41,15 @@ class SearchBoxProvider : AppWidgetProvider() {
// Perform this loop procedure for each widget that belongs to this
// provider.
appWidgetIds.forEach { appWidgetId ->
- // Create an Intent to launch ExampleActivity.
+ // Create an Intent that launches the search activity.
+ val gugalLaunchIntent = Intent(context, MainActivity::class.java)
+ gugalLaunchIntent.putExtra("autoSelectSearch", true)
+
+ // Create a PendingIntent for that Intent.
val pendingIntent: PendingIntent = PendingIntent.getActivity(
/* context = */ context,
/* requestCode = */ 0,
- /* intent = */ Intent(context, MainActivity::class.java),
+ /* intent = */ gugalLaunchIntent,
/* flags = */ PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
)
diff --git a/app/src/main/java/com/porg/gugal/URLs.kt b/app/src/main/java/com/porg/gugal/URLs.kt
index ca5f9f1b73898fb257f42ebba65736a862cb1a81..492495ef6eda1eba14ea5a74efa612d1410281fb 100644
--- a/app/src/main/java/com/porg/gugal/URLs.kt
+++ b/app/src/main/java/com/porg/gugal/URLs.kt
@@ -1,7 +1,7 @@
/*
* URLs.kt
* Gugal
- * Copyright (c) 2023 thegreatporg
+ * Copyright (c) 2023-2025 the Gugal maintainers
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
@@ -30,13 +30,20 @@ package com.porg.gugal
const val SUGGESTED_FEED_LIST_URL = "https://gitlab.com/gugal/feeds/-/raw/main/feeds_min.json"
/**
- * The site to report issues to. Shown in the About page if the current Gugal version is a preview build.
+ * The site to report preview version issues to. Shown in the About page if the current Gugal
+ * version is a preview build.
*
* Please change this URL before releasing a fork; upstream Gugal isn't accepting issues in
* forks, especially if the issue isn't present in upstream.
*/
const val PREVIEW_ISSUE_URL = "https://gitlab.com/narektor/gugal/-/issues/new?issue[description]=/label%20~beta%20%3C!--%20write%20below%20this%20line%20--%3E"
+/**
+ * Information about test builds. Shown in the About page if the current Gugal version is a
+ * test build.
+ */
+const val TEST_INFO_URL = "https://gugal.gitlab.io/test-versions.html"
+
/**
* The site to report issues to. Shown in the About page.
*
@@ -51,7 +58,7 @@ const val ISSUE_URL = "https://gitlab.com/narektor/gugal/-/issues/new"
* Please change this URL before releasing a fork; upstream Gugal isn't accepting issues in
* forks, especially if the issue isn't present in upstream.
*/
-const val NEWS_ISSUE_URL = "https://gitlab.com/narektor/gugal/-/issues/27"
+const val NEWS_ISSUE_URL = ISSUE_URL
/**
* The URL of the app's repository.
diff --git a/app/src/main/java/com/porg/gugal/common/AESCrypt.kt b/app/src/main/java/com/porg/gugal/common/AESCrypt.kt
new file mode 100644
index 0000000000000000000000000000000000000000..5cbb5a8e9bbac60c944c04c444a1b81ace09952c
--- /dev/null
+++ b/app/src/main/java/com/porg/gugal/common/AESCrypt.kt
@@ -0,0 +1,67 @@
+/*
+ * AESCrypt.kt
+ * Gugal
+ * Copyright © 2025 the Gugal maintainers
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ */
+
+package com.porg.gugal.common
+
+import android.util.Base64
+import java.security.Key
+import javax.crypto.Cipher
+import javax.crypto.spec.IvParameterSpec
+import javax.crypto.spec.SecretKeySpec
+
+data class AESEncryptionResult(
+ val result: String,
+ val iv: String
+)
+
+class AESCrypt(
+ val key: String = "QABx/GkkXBQJTARYSCFMsARoMSBcYVBQGBQAuWw=="
+) {
+ val ALGORITHM = "AES/CBC/PKCS5Padding"
+
+ @Throws(Exception::class)
+ fun encrypt(value: String): AESEncryptionResult {
+ val key: Key = generateKey()
+ val cipher = Cipher.getInstance(ALGORITHM)
+ cipher.init(Cipher.ENCRYPT_MODE, key)
+ val encryptedByteValue = cipher.doFinal(value.toByteArray(charset("utf-8")))
+ val encryptedValue64: String = Base64.encodeToString(encryptedByteValue, Base64.NO_WRAP)
+ return AESEncryptionResult(
+ result = encryptedValue64,
+ iv = Base64.encodeToString(cipher.iv, Base64.NO_WRAP)
+ )
+ }
+
+ @Throws(Exception::class)
+ fun decrypt(value: String?, iv: String): String {
+ val key: Key = generateKey()
+ val cipher = Cipher.getInstance(ALGORITHM)
+ cipher.init(Cipher.DECRYPT_MODE, key, IvParameterSpec(Base64.decode(iv, Base64.NO_WRAP)))
+ val decryptedValue64: ByteArray = Base64.decode(value, Base64.NO_WRAP)
+ val decryptedByteValue = cipher.doFinal(decryptedValue64)
+ val decryptedValue = String(decryptedByteValue, charset("utf-8"))
+ return decryptedValue
+ }
+
+ @Throws(Exception::class)
+ private fun generateKey(): Key {
+ val key: Key = SecretKeySpec(key.toByteArray(), ALGORITHM)
+ return key
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/com/porg/gugal/common/KeyDerivation.kt b/app/src/main/java/com/porg/gugal/common/KeyDerivation.kt
new file mode 100644
index 0000000000000000000000000000000000000000..e5afb1e72ef99975985b83974ea978722de0fca6
--- /dev/null
+++ b/app/src/main/java/com/porg/gugal/common/KeyDerivation.kt
@@ -0,0 +1,63 @@
+/*
+ * KeyDerivation.kt
+ * Gugal
+ * Copyright © 2025 the Gugal maintainers
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ */
+
+package com.porg.gugal.common
+
+import android.os.Build
+import java.security.SecureRandom
+import java.security.spec.KeySpec
+import java.util.HexFormat
+import javax.crypto.SecretKey
+import javax.crypto.SecretKeyFactory
+import javax.crypto.spec.PBEKeySpec
+
+class KeyDerivation {
+ companion object {
+ fun generateRandomSalt(): ByteArray {
+ val random = SecureRandom()
+ val salt = ByteArray(8)
+ random.nextBytes(salt)
+ return salt
+ }
+
+ private fun ByteArray.toHexString(): String =
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) {
+ HexFormat.of().formatHex(this)
+ } else {
+ joinToString(separator = "") { eachByte -> "%02x".format(eachByte) }
+ }
+
+ private val ALGORITHM = "PBKDF2WithHmacSHA256"
+ private val ITERATIONS = 15_000
+ private val KEY_LENGTH = 128
+ private val SECRET = "FQUbCgI3UgUKBg1TDQMRT1M2FQ1ABhBTHBcFAB5/SB0BGQQcVFo"
+
+ fun generateHash(password: String, salt: String): String {
+ val combinedSalt = "$salt$SECRET".toByteArray()
+
+ val factory: SecretKeyFactory = SecretKeyFactory.getInstance(ALGORITHM)
+ val spec: KeySpec =
+ PBEKeySpec(password.toCharArray(), combinedSalt, ITERATIONS, KEY_LENGTH)
+ val key: SecretKey = factory.generateSecret(spec)
+ val hash: ByteArray = key.encoded
+
+ return hash.toHexString()
+ }
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/com/porg/gugal/common/PasswordCheck.kt b/app/src/main/java/com/porg/gugal/common/PasswordCheck.kt
new file mode 100644
index 0000000000000000000000000000000000000000..d1ee753becf60365931fe8ada3b596f2e8440973
--- /dev/null
+++ b/app/src/main/java/com/porg/gugal/common/PasswordCheck.kt
@@ -0,0 +1,266 @@
+/*
+ * PasswordCheck.kt
+ * Copyright © 2025 the Gugal maintainers
+ * Adapted from https://github.com/djngalja/PasswordStrengthTest
+ *
+ * This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at https://mozilla.org/MPL/2.0/.
+ */
+
+package com.porg.gugal.common
+
+enum class PasswordScore {
+ VERY_WEAK,
+ WEAK,
+ MEDIUM,
+ STRONG,
+ VERY_STRONG;
+
+ companion object {
+ /**
+ * Converts an integer score to a PasswordScore.
+ */
+ fun fromIntScore(score: Int): PasswordScore {
+ return if (score <= 2) VERY_WEAK
+ else if (score <= 4) WEAK
+ else if (score <= 6) MEDIUM
+ else if (score <= 8) STRONG
+ else VERY_STRONG
+ }
+ }
+}
+
+
+
+class PasswordCheck {
+ companion object {
+ const val minPatternLength = 4
+
+ /**
+ * Check a password and return an integer score.
+ *
+ * Total score is calculated based on the following rules:
+ * * +1 point for every 8 characters
+ * * +1 point for numbers
+ * * +1 point for upper case letters
+ * * +1 point for lower case letters
+ * * +1 point for every special character
+ * * -1 point for every common pattern
+ */
+ fun grade(password: String): Int {
+ val digits = containsDigit(password)
+ val upperCase = containsUpperCase(password)
+ val lowerCase = containsLowerCase(password)
+ val specialChars = countSpecialChars(password)
+ val patternsFound = mutableSetOf()
+
+ // add elements to 'patternsFound' Set
+ findCommonPatterns(password, patternsFound)
+ findRepeatingChars(password, patternsFound)
+ findRepeatingPairs(password, patternsFound)
+ findAbcPatterns(password, patternsFound)
+ findAbcPatterns(password, patternsFound, true)
+
+ return password.length / 8 + digits + upperCase + lowerCase + specialChars - patternsFound.size
+ }
+
+ /**
+ * Check a password and return a PasswordScore.
+ */
+ fun gradeAsScore(password: String): PasswordScore {
+ return PasswordScore.fromIntScore(grade(password))
+ }
+
+ private fun containsDigit(password: String): Int {
+ for (c in password) if (c.isDigit()) return 1
+ return 0
+ }
+
+ private fun containsUpperCase(password: String): Int {
+ for (c in password) if (c.isUpperCase()) return 1
+ return 0
+ }
+
+ private fun containsLowerCase(password: String): Int {
+ for (c in password) if (c.isLowerCase()) return 1
+ return 0
+ }
+
+ private fun countSpecialChars(password: String): Int {
+ var count = 0
+ for (c in password) if (!(c.isLetterOrDigit())) count++
+ return count
+ }
+
+ /*
+ * Checks whether 'password' contains
+ * any password patterns from 'patterns' Array ('admin', 'qWeRty', 'ILoveYou', etc.).
+ * These substrings are added to 'patternsFound'.
+ */
+ private fun findCommonPatterns(password: String, patternsFound: MutableSet) {
+ val patterns = arrayOf( //add common passwords (in lower case) to this Array
+ "123123",
+ "123321",
+ "123qwe",
+ "1q2w3e",
+ "abc123",
+ "admin",
+ "dragon",
+ "iloveyou",
+ "lovely",
+ "password",
+ "qwerty",
+ "welcome"
+ )
+ var shortestPatternLength = patterns[0].length
+ for (pattern in patterns) {
+ if (pattern.length < shortestPatternLength)
+ shortestPatternLength = pattern.length
+ }
+ if (password.length < shortestPatternLength) return
+ for (pattern in patterns) {
+ if (pattern.length > password.length) continue
+ var i = 0
+ while (i <= password.length - pattern.length) {
+ var tempString = ""
+ if (password[i].lowercase() == pattern[0].toString()) {
+ for (j in pattern.indices) {
+ if (password[i + j].lowercase() == pattern[j].toString())
+ tempString += password[i + j]
+ else break
+ }
+ if (tempString.length == pattern.length) {
+ patternsFound.add(tempString)
+ i += pattern.length - 1
+ }
+ }
+ i++
+ }
+ }
+ }
+
+ /*
+ * Checks whether 'password' contains
+ * any repeating characters ('1111', 'hhhh', 'aAaaa', '++++', etc.).
+ * These substrings are added to 'patternsFound'.
+ * Each substring is at least minPatternLength characters long.
+ */
+ private fun findRepeatingChars(password: String, patternsFound: MutableSet) {
+ var i = 0
+ while (i <= password.length - minPatternLength) {
+ //looking for patterns not shorter than minPatternLength
+ var tempString = ""
+ if (password[i].lowercase() == password[i + 1].lowercase()) { //2 repeating Chars found
+ tempString += password[i]
+ while (
+ i < password.length - 1
+ && password[i].lowercase() == password[i + 1].lowercase()
+ ) {
+ tempString += password[i + 1]
+ i++
+ }
+ }
+ if (tempString.length >= minPatternLength)
+ patternsFound.add(tempString)
+ i++
+ }
+ }
+
+ /*
+ * Checks whether 'password' contains
+ * any repeating pairs of characters ('1212', 'HAHAhaha', etc.).
+ * These substrings are added to 'patternsFound'.
+ * Each substring is at least minPatternLength characters long.
+ */
+ private fun findRepeatingPairs(password: String, patternsFound: MutableSet) {
+ if (password.length < 4) return
+ var i = 0
+ while (i <= password.length - minPatternLength) {
+ //looking for patterns not shorter than minPatternLength
+ var tempString = ""
+ if (
+ password[i].lowercase() == password[i + 2].lowercase()
+ && password[i + 1].lowercase() == password[i + 3].lowercase()
+ && password[i].lowercase() != password[i + 1].lowercase()
+ ) {
+ tempString += password[i]
+ tempString += password[i + 1]
+ while (
+ i < password.length - 3
+ && password[i].lowercase() == password[i + 2].lowercase()
+ && password[i + 1].lowercase() == password[i + 3].lowercase()
+ ) {
+ tempString += password[i + 2]
+ tempString += password[i + 3]
+ i += 2
+ }
+ }
+ if (tempString.length >= minPatternLength) {
+ patternsFound.add(tempString)
+ i += 2
+ } else i++
+ }
+ }
+
+ /*
+ * By default, checks whether 'password' contains
+ * any arithmetic sequences with a common difference of 1 ('1234', '34567', etc.)
+ * or sequences of letters in alphabetical order ('abcd', 'ABcDe', 'hIjKLMn', etc.).
+ * If 'backwards' is set to 'true', checks whether 'password' contains
+ * arithmetic sequences with a common difference od -1 ('4321', '76543', etc.)
+ * or sequences of letters in reverse alphabetical order ('dcba', 'eDcBA', 'nMLKjIh', etc.).
+ * These substrings are added to 'patternsFound'.
+ * Each substring is at least minPatternLength characters long.
+ */
+ private fun findAbcPatterns(password: String, patternsFound: MutableSet, backwards: Boolean = false) {
+ if (password.length < minPatternLength) return
+ val d = if (backwards) -1 else 1 // common difference
+ var i = 0
+ while (i <= password.length - minPatternLength) {
+ //looking for sequences not shorter than minPatternLength
+ var tempString = ""
+ if (
+ (password[i].code + d == password[i + 1].code
+ && password[i].isLetterOrDigit())
+ ||
+ (password[i].isUpperCase()
+ && password[i + 1].isLowerCase()
+ && password[i].code + d + 32 == password[i + 1].code)
+ //convert password[i] + d to lower case
+ ||
+ (password[i].isLowerCase()
+ && password[i + 1].isUpperCase()
+ && password[i].code + d - 32 == password[i + 1].code)
+ //convert password[i] + d to upper case
+ ) {
+ tempString += password[i]
+ while (
+ i < password.length - 1
+ &&
+ (
+ (password[i].code + d == password[i + 1].code
+ && password[i].isLetterOrDigit())
+ ||
+ (password[i].isUpperCase()
+ && password[i + 1].isLowerCase()
+ && password[i].code + d + 32 == password[i + 1].code)
+ //convert password[i] + d to lower case
+ ||
+ (password[i].isLowerCase()
+ && password[i + 1].isUpperCase()
+ && password[i].code + d - 32 == password[i + 1].code)
+ //convert password[i] + d to upper case
+ )
+ ) {
+ tempString += password[i + 1]
+ i++
+ }
+ }
+ if (tempString.length >= minPatternLength)
+ patternsFound.add(tempString)
+ i++
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/com/porg/gugal/data/ResultsDeprecatedPlaceholder.kt b/app/src/main/java/com/porg/gugal/data/ResultsDeprecatedPlaceholder.kt
new file mode 100644
index 0000000000000000000000000000000000000000..9349f748e460c9188f37f24479596b19ba7e1ec5
--- /dev/null
+++ b/app/src/main/java/com/porg/gugal/data/ResultsDeprecatedPlaceholder.kt
@@ -0,0 +1,103 @@
+/*
+ * ResultsDeprecatedPlaceholder.kt
+ * Gugal
+ * Copyright © 2025 the Gugal maintainers
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ */
+
+/*
+ * This file defines stubs for classes that were moved to com.porg.gugal.data.results.
+ */
+
+package com.porg.gugal.data
+
+import com.porg.gugal.data.results.BaseResult
+import com.porg.gugal.data.results.ResultMetadata
+import com.porg.gugal.experimental.ExperimentalSerpProviderApi
+
+@Deprecated(
+ "Moved to com.porg.gugal.data.results.",
+ ReplaceWith(
+ "com.porg.gugal.data.results.BaseResult",
+ "com.porg.gugal.data.results"
+ )
+)
+open class BaseResult
+
+/**
+ * An image search result.
+ *
+ * @param title the image's title, shown first and large.
+ * @param url the image's URL.
+ * @param sourceUrl the image's site's URL, will be opened when "Go to site" is tapped.
+ * @param domain the page's domain, shown in small text below the title.
+ */
+@Deprecated(
+ "Moved to com.porg.gugal.data.results.",
+ ReplaceWith(
+ "com.porg.gugal.data.results.ImageResult",
+ "com.porg.gugal.data.results"
+ )
+)
+data class ImageResult(val title: String, val url: String, val sourceUrl: String, val domain: String): BaseResult()
+
+/**
+ * A search result.
+ *
+ * @param title the page's title, shown first and large.
+ * @param body an optional snippet from the page shown as the result's body.
+ * If none was found or the search engine doesn't support them set to null.
+ * @param url the page's URL, will be opened when the result is tapped.
+ * @param domain the page's domain, shown in small text below the title.
+ */
+@Deprecated(
+ "Moved to com.porg.gugal.data.results.",
+ ReplaceWith(
+ "com.porg.gugal.data.results.Result",
+ "com.porg.gugal.data.results"
+ )
+)
+data class Result(val title: String, val body: String?, val url: String, val domain: String): BaseResult()
+
+/**
+ * Base class for any kind of metadata.
+ */
+@Deprecated(
+ "Moved to com.porg.gugal.data.results.",
+ ReplaceWith(
+ "com.porg.gugal.data.results.ResultMetadata",
+ "com.porg.gugal.data.results"
+ )
+)
+@ExperimentalSerpProviderApi
+open class ResultMetadata
+
+/**
+ * Defines metadata about products in results.
+ */
+@ExperimentalSerpProviderApi
+@Deprecated(
+ "Moved to com.porg.gugal.data.results.",
+ ReplaceWith(
+ "com.porg.gugal.data.results.ResultMetadata",
+ "com.porg.gugal.data.results"
+ )
+)
+class ResultMetadataProduct(
+ val name: String,
+ val body: String?,
+ val currency: String,
+ val price: String
+): ResultMetadata()
\ No newline at end of file
diff --git a/app/src/main/java/com/porg/gugal/data/container/AppContainer.kt b/app/src/main/java/com/porg/gugal/data/container/AppContainer.kt
new file mode 100644
index 0000000000000000000000000000000000000000..8044a28f2cd995dcbdc9ca12643f4fc3b501e846
--- /dev/null
+++ b/app/src/main/java/com/porg/gugal/data/container/AppContainer.kt
@@ -0,0 +1,43 @@
+/*
+ * AppContainer.kt
+ * Gugal
+ * Copyright © 2025 the Gugal maintainers
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ */
+
+package com.porg.gugal.data.container
+
+import com.porg.gugal.providers.SerpProvider
+
+/**
+ * Provides various resources.
+ */
+interface AppContainer {
+ /**
+ * The current SERP provider.
+ */
+ val serpProvider: SerpProvider?
+
+ /**
+ * A list of available experiments.
+ */
+ val experiments: MutableMap
+
+ /**
+ * Is demo mode on?
+ */
+ var demoMode: Boolean
+}
+
diff --git a/app/src/main/java/com/porg/gugal/data/container/DefaultAppContainer.kt b/app/src/main/java/com/porg/gugal/data/container/DefaultAppContainer.kt
new file mode 100644
index 0000000000000000000000000000000000000000..7d05463dbc59aa37ac90f8c60a20f51a5a5d5ed1
--- /dev/null
+++ b/app/src/main/java/com/porg/gugal/data/container/DefaultAppContainer.kt
@@ -0,0 +1,37 @@
+/*
+ * DefaultAppContainer.kt
+ * Gugal
+ * Copyright © 2025 the Gugal maintainers
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ */
+
+package com.porg.gugal.data.container
+
+import com.porg.gugal.Global
+import com.porg.gugal.providers.SerpProvider
+
+class DefaultAppContainer: AppContainer {
+ override val serpProvider: SerpProvider? = null
+ override val experiments: MutableMap = mutableMapOf(
+ "gugalNews" to Experiment("News", false),
+ "custTabInResults" to Experiment("Custom tabs for results", false),
+ "resultGrid" to Experiment("Use grid for results (tablets only)", false),
+ "searchPages" to Experiment("Search result pagination", false),
+ "backup" to Experiment("Backup and restore", false),
+ "gugalNewsSetup" to Experiment("News - Setup pages", false, Global.gugalNews),
+ "expressive" to Experiment("Expressive redesign", false)
+ )
+ override var demoMode: Boolean = false
+}
\ No newline at end of file
diff --git a/app/src/main/java/com/porg/gugal/data/container/Experiment.kt b/app/src/main/java/com/porg/gugal/data/container/Experiment.kt
new file mode 100644
index 0000000000000000000000000000000000000000..78c777f185d968ad72de8bb57de435c3df71e592
--- /dev/null
+++ b/app/src/main/java/com/porg/gugal/data/container/Experiment.kt
@@ -0,0 +1,61 @@
+/*
+ * Experiment.kt
+ * Gugal
+ * Copyright © 2025 the Gugal maintainers
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ */
+
+package com.porg.gugal.data.container
+
+data class Experiment(
+ val title: String,
+ var enabled: Boolean,
+ var dependsOnExperiment: Experiment? = null
+) {
+ /**
+ * Toggles this experiment.
+ *
+ * @return the new state of the experiment.
+ */
+ fun toggle(): Boolean {
+ enabled = !enabled
+ return enabled
+ }
+
+ /**
+ * Enables this experiment.
+ */
+ fun enable() {
+ enabled = true
+ }
+
+ /**
+ * Disables this experiment.
+ */
+ fun disable() {
+ enabled = false
+ }
+
+ companion object {
+ val ID_GUGAL_NEWS = "gugalNews"
+ val ID_CUSTOM_TABS = "custTabInResults"
+ val ID_BACKUP = "backup"
+ val ID_RESULT_GRID = "resultGrid"
+ val ID_SEARCH_PAGES = "searchPages"
+ val ID_EXPRESSIVE = "expressive"
+ val ID_NEWS_SETUP = "gugalNewsSetup"
+ val ID_BOTTOM_SEARCH = "bottomSearchField"
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/com/porg/gugal/data/preferences/PreferenceBackup.kt b/app/src/main/java/com/porg/gugal/data/preferences/PreferenceBackup.kt
new file mode 100644
index 0000000000000000000000000000000000000000..62266759d0e765c85e189aaaf1d60d0ba31132d6
--- /dev/null
+++ b/app/src/main/java/com/porg/gugal/data/preferences/PreferenceBackup.kt
@@ -0,0 +1,49 @@
+/*
+ * PreferenceBackup.kt
+ * Gugal
+ * Copyright © 2025 the Gugal maintainers
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ */
+
+package com.porg.gugal.data.preferences
+
+/**
+ * A backup of Gugal preferences.
+ */
+data class PreferenceBackup (
+ /**
+ * The backup format version.
+ */
+ val gpbVersion: Int,
+ /**
+ * The Gugal version.
+ */
+ val gugalVersion: String,
+ /**
+ * The preferences stored inside.
+ * If null, the backup might be encrypted; check [encryptedPreferences].
+ */
+ val preferences: Map? = null,
+ /**
+ * The preferences stored inside, in encrypted form.
+ *
+ * This object has the form `(algorithm),(key salt),(encrypted preferences json)`.
+ * The preferences JSON is encrypted with the provided algorithm (a java description)
+ * and a key generated by performing PBKDF2 on the password with the provided salt.
+ *
+ * If null, the backup might be unencrypted; check [preferences].
+ */
+ val encryptedPreferences: String? = null
+)
diff --git a/app/src/main/java/com/porg/gugal/Experiment.kt b/app/src/main/java/com/porg/gugal/data/preferences/Preferences.kt
similarity index 77%
rename from app/src/main/java/com/porg/gugal/Experiment.kt
rename to app/src/main/java/com/porg/gugal/data/preferences/Preferences.kt
index 969eeef41b7e902b0de5fe72eda0f12366f41dff..67e208a2c10eeb1da4f5bfab3e945f0b6c03873c 100644
--- a/app/src/main/java/com/porg/gugal/Experiment.kt
+++ b/app/src/main/java/com/porg/gugal/data/preferences/Preferences.kt
@@ -1,7 +1,7 @@
/*
- * Experiment.kt
+ * Preferences.kt
* Gugal
- * Copyright (c) 2022 thegreatporg
+ * Copyright © 2025-2025 the Gugal maintainers
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
@@ -17,10 +17,9 @@
* along with this program. If not, see .
*/
-package com.porg.gugal
+package com.porg.gugal.data.preferences
-data class Experiment(
- val title: String,
- var enabled: Boolean,
- var dependsOnExperiment: Experiment? = null
-)
\ No newline at end of file
+data class Preferences(
+ val serpProvider: String,
+
+)
diff --git a/app/src/main/java/com/porg/gugal/data/BaseResult.kt b/app/src/main/java/com/porg/gugal/data/results/BaseResult.kt
similarity index 81%
rename from app/src/main/java/com/porg/gugal/data/BaseResult.kt
rename to app/src/main/java/com/porg/gugal/data/results/BaseResult.kt
index 339a0e3337dccbc6fe268550e0cbfe603bb43673..578ef0ebf1d7e5184973f1ac20d9407661cefb07 100644
--- a/app/src/main/java/com/porg/gugal/data/BaseResult.kt
+++ b/app/src/main/java/com/porg/gugal/data/results/BaseResult.kt
@@ -1,7 +1,7 @@
/*
* BaseResult.kt
* Gugal
- * Copyright (c) 2023 thegreatporg
+ * Copyright (c) 2023-2025 the Gugal maintainers
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
@@ -17,6 +17,10 @@
* along with this program. If not, see .
*/
-package com.porg.gugal.data
+package com.porg.gugal.data.results
+/**
+ * A base search result. It's discouraged to use
+ * this class as is.
+ */
open class BaseResult
\ No newline at end of file
diff --git a/app/src/main/java/com/porg/gugal/data/ImageResult.kt b/app/src/main/java/com/porg/gugal/data/results/ImageResult.kt
similarity index 86%
rename from app/src/main/java/com/porg/gugal/data/ImageResult.kt
rename to app/src/main/java/com/porg/gugal/data/results/ImageResult.kt
index f1e599cab12350be89c67954b72dea63f7037795..00e7d712628b22bf97b6c7c58bf08854642728ab 100644
--- a/app/src/main/java/com/porg/gugal/data/ImageResult.kt
+++ b/app/src/main/java/com/porg/gugal/data/results/ImageResult.kt
@@ -1,7 +1,7 @@
/*
* ImageResult.kt
* Gugal
- * Copyright (c) 2023 thegreatporg
+ * Copyright © 2025 the Gugal maintainers
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
@@ -17,7 +17,9 @@
* along with this program. If not, see .
*/
-package com.porg.gugal.data
+package com.porg.gugal.data.results
+
+import com.porg.gugal.experimental.ExperimentalSerpProviderApi
/**
* An image search result.
@@ -27,4 +29,5 @@ package com.porg.gugal.data
* @param sourceUrl the image's site's URL, will be opened when "Go to site" is tapped.
* @param domain the page's domain, shown in small text below the title.
*/
+@ExperimentalSerpProviderApi
data class ImageResult(val title: String, val url: String, val sourceUrl: String, val domain: String): BaseResult()
\ No newline at end of file
diff --git a/app/src/main/java/com/porg/gugal/data/Result.kt b/app/src/main/java/com/porg/gugal/data/results/Result.kt
similarity index 74%
rename from app/src/main/java/com/porg/gugal/data/Result.kt
rename to app/src/main/java/com/porg/gugal/data/results/Result.kt
index f1cda9d9bddd3dda409fd209a5d2414fc33771af..52fc1741569fa3fa6aab0f28c049a06082dd9356 100644
--- a/app/src/main/java/com/porg/gugal/data/Result.kt
+++ b/app/src/main/java/com/porg/gugal/data/results/Result.kt
@@ -1,7 +1,7 @@
/*
* Result.kt
* Gugal
- * Copyright (c) 2021 thegreatporg
+ * Copyright © 2025 the Gugal maintainers
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
@@ -17,7 +17,9 @@
* along with this program. If not, see .
*/
-package com.porg.gugal.data
+package com.porg.gugal.data.results
+
+import androidx.compose.ui.text.AnnotatedString
/**
* A search result.
@@ -27,5 +29,13 @@ package com.porg.gugal.data
* If none was found or the search engine doesn't support them set to null.
* @param url the page's URL, will be opened when the result is tapped.
* @param domain the page's domain, shown in small text below the title.
+ * @param bodyAnnotated annotated version of [body], shown as the body if
+ * not null.
*/
-data class Result(val title: String, val body: String?, val url: String, val domain: String): BaseResult()
\ No newline at end of file
+data class Result(
+ val title: String,
+ val body: String?,
+ val url: String,
+ val domain: String,
+ val bodyAnnotated: AnnotatedString? = null
+): BaseResult()
\ No newline at end of file
diff --git a/app/src/main/java/com/porg/gugal/data/ResultMetadata.kt b/app/src/main/java/com/porg/gugal/data/results/ResultMetadata.kt
similarity index 81%
rename from app/src/main/java/com/porg/gugal/data/ResultMetadata.kt
rename to app/src/main/java/com/porg/gugal/data/results/ResultMetadata.kt
index 9f3ef32eb6403020ba3d63ec1f6bd005a8ca200a..ea94e5f092f6ae32df3caf469a1e363f45dc081b 100644
--- a/app/src/main/java/com/porg/gugal/data/ResultMetadata.kt
+++ b/app/src/main/java/com/porg/gugal/data/results/ResultMetadata.kt
@@ -1,7 +1,7 @@
/*
* RichResult.kt
* Gugal
- * Copyright (c) 2023 thegreatporg
+ * Copyright (c) 2023-2025 the Gugal maintainers
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
@@ -17,9 +17,12 @@
* along with this program. If not, see .
*/
-package com.porg.gugal.data
+package com.porg.gugal.data.results
+
+import com.porg.gugal.experimental.ExperimentalSerpProviderApi
/**
* Base class for any kind of metadata.
*/
+@ExperimentalSerpProviderApi
open class ResultMetadata
\ No newline at end of file
diff --git a/app/src/main/java/com/porg/gugal/data/ResultMetadataProduct.kt b/app/src/main/java/com/porg/gugal/data/results/ResultMetadataProduct.kt
similarity index 84%
rename from app/src/main/java/com/porg/gugal/data/ResultMetadataProduct.kt
rename to app/src/main/java/com/porg/gugal/data/results/ResultMetadataProduct.kt
index c47a688d9fd916ee01617c4a93406c8387b5e9a4..67f813bc108e468f10cef095f87b3117c4a36ce8 100644
--- a/app/src/main/java/com/porg/gugal/data/ResultMetadataProduct.kt
+++ b/app/src/main/java/com/porg/gugal/data/results/ResultMetadataProduct.kt
@@ -1,7 +1,7 @@
/*
* ResultMetadataProduct.kt
* Gugal
- * Copyright (c) 2023 thegreatporg
+ * Copyright © 2025 the Gugal maintainers
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
@@ -17,11 +17,14 @@
* along with this program. If not, see .
*/
-package com.porg.gugal.data
+package com.porg.gugal.data.results
+
+import com.porg.gugal.experimental.ExperimentalSerpProviderApi
/**
* Defines metadata about products in results.
*/
+@ExperimentalSerpProviderApi
class ResultMetadataProduct(
val name: String,
val body: String?,
diff --git a/app/src/main/java/com/porg/gugal/devopt/DevOptExperimentActivity.kt b/app/src/main/java/com/porg/gugal/devopt/DevOptExperimentActivity.kt
index 05f1338e5725cb64292e3b4fff32c4aa711e8e3f..ccad696e3a96aef44e170669064b575c3e006f92 100644
--- a/app/src/main/java/com/porg/gugal/devopt/DevOptExperimentActivity.kt
+++ b/app/src/main/java/com/porg/gugal/devopt/DevOptExperimentActivity.kt
@@ -1,7 +1,7 @@
/*
* DevOptExperimentActivity.kt
* Gugal
- * Copyright (c) 2022 thegreatporg
+ * Copyright (c) 2022-2025 the Gugal maintainers
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
@@ -25,62 +25,58 @@ import android.widget.Toast
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.compose.animation.ExperimentalAnimationApi
-import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize
-import androidx.compose.foundation.rememberScrollState
-import androidx.compose.foundation.verticalScroll
+import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.material.ExperimentalMaterialApi
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
+import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
-import com.porg.gugal.Experiment
-import com.porg.gugal.Global.Companion.custTabInResults
-import com.porg.gugal.Global.Companion.gugalNews
-import com.porg.gugal.Global.Companion.gugalNewsSetup
-import com.porg.gugal.Global.Companion.resultGrid
-import com.porg.gugal.Global.Companion.searchPages
-import com.porg.gugal.MainActivity
+import com.porg.gugal.data.container.Experiment
+import com.porg.gugal.GugalApplication
+import com.porg.gugal.ui.MainActivity
import com.porg.gugal.ui.theme.GugalTheme
-import com.porg.m3.settings.RegularSetting
+import tech.cataspect.m3x.settings.ToggleSetting
class DevOptExperimentActivity : ComponentActivity() {
- val tick = "✔️"
+ private val experimentTags: Map = mapOf(
+ "gugalNews" to "Toggles the News tab, featuring a clean news feed powered by RSS, for this session.",
+ "custTabInResults" to "If enabled, tapping on search results will open custom tabs instead of opening a tab in the browser.",
+ "resultGrid" to "If enabled, the results are shown as a grid instead of a padded row. Only works on tablets/foldables and has no effect on phones.",
+ "searchPages" to "Adds pages to the search result list.",
+ "backup" to "Adds an option to back up/restore encrypted preferences.",
+ "gugalNewsSetup" to "Adds setup pages for news to the setup wizard.",
+ "expressive" to "(Mockup only!) Material 3 Expressive redesign"
+ )
+
@OptIn(
androidx.compose.material3.ExperimentalMaterial3Api::class
)
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
+
+ val experiments = (application as GugalApplication).container.experiments
+
setContent {
GugalTheme {
// A surface container using the 'background' color from the theme
Surface(color = MaterialTheme.colorScheme.background) {
- Column(
- modifier = Modifier.fillMaxSize().verticalScroll(rememberScrollState())
+ LazyColumn(
+ modifier = Modifier.fillMaxSize()
) {
- ExperimentSetting(
- body = "Toggles the News tab, featuring a clean news feed powered by RSS, for this session.",
- experiment = gugalNews
- )
- if (gugalNews.enabled) {
- ExperimentSetting(
- body = "Adds setup pages for news to the setup wizard.",
- experiment = gugalNewsSetup
- )
+ items(experiments.keys.count()) { idx ->
+ val expKey = experiments.keys.elementAt(idx)
+ if (expKey !in experiments) {
+ Text("invalid experiment (idx=$idx key=$expKey), check app container")
+ } else {
+ ExperimentSetting(
+ experiment = experiments[expKey]!!,
+ body = experimentTags[expKey]
+ )
+ }
}
- ExperimentSetting(
- body = "If enabled, tapping on search results will open custom tabs instead of opening a tab in the browser.",
- experiment = custTabInResults
- )
- ExperimentSetting(
- body = "If enabled, the results are shown as a grid instead of a padded row. Only works on tablets/foldables and has no effect on phones.",
- experiment = resultGrid
- )
- ExperimentSetting(
- body = "Adds pages to the search result list.",
- experiment = searchPages
- )
}
}
}
@@ -89,26 +85,18 @@ class DevOptExperimentActivity : ComponentActivity() {
@OptIn(ExperimentalAnimationApi::class, ExperimentalMaterialApi::class)
@Composable
- private fun ExperimentSetting(experiment: Experiment, body: String) {
- RegularSetting(
- title = createTitle(experiment),
- body = body,
- onClick = {
- experiment.enabled = !experiment.enabled
- var ed = "en"; if (!experiment.enabled) ed = "dis"
- doExtraActions(experiment)
+ private fun ExperimentSetting(experiment: Experiment, body: String?) {
+ ToggleSetting(
+ title = experiment.title,
+ body = body ?: "No description.",
+ onCheckedChange = { x ->
+ experiment.enabled = x
+ val ed = if (experiment.enabled) "en" else "dis"
Toast.makeText(applicationContext, "${experiment.title} has been ${ed}abled for this session.",
Toast.LENGTH_SHORT).show()
startActivity(Intent(applicationContext, MainActivity::class.java))
},
+ selected = experiment.enabled
)
}
-
- private fun doExtraActions(exp: Experiment) {
- }
-
- private fun createTitle(experiment: Experiment): String {
- return if (experiment.enabled) "${experiment.title} $tick"
- else experiment.title
- }
}
\ No newline at end of file
diff --git a/app/src/main/java/com/porg/gugal/devopt/DevOptMainActivity.kt b/app/src/main/java/com/porg/gugal/devopt/DevOptMainActivity.kt
index 538f3c9c7af4172882b9c67347a156cfbed2273a..e4d46c98402139c5060f8d25cbf29dccf7efeb8f 100644
--- a/app/src/main/java/com/porg/gugal/devopt/DevOptMainActivity.kt
+++ b/app/src/main/java/com/porg/gugal/devopt/DevOptMainActivity.kt
@@ -1,7 +1,7 @@
/*
* DevOptMainActivity.kt
* Gugal
- * Copyright (c) 2022 thegreatporg
+ * Copyright (c) 2022-2025 the Gugal maintainers
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
@@ -31,13 +31,14 @@ import androidx.compose.material.ExperimentalMaterialApi
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.ui.Modifier
-import com.porg.gugal.Global
-import com.porg.gugal.MainActivity
+import com.porg.gugal.GugalApplication
import com.porg.gugal.R
import com.porg.gugal.setup.SetupNewStartActivity
+import com.porg.gugal.ui.MainActivity
import com.porg.gugal.ui.theme.GugalTheme
-import com.porg.m3.Tip
-import com.porg.m3.settings.RegularSetting
+import tech.cataspect.m3x.SetupPaddingModifier
+import tech.cataspect.m3x.Tip
+import tech.cataspect.m3x.settings.RegularSetting
class DevOptMainActivity : ComponentActivity() {
@OptIn(
@@ -57,8 +58,8 @@ class DevOptMainActivity : ComponentActivity() {
) {
Tip(
text = "Be careful! The settings here are intended for development only and might lower security or break Gugal.",
- modifier = com.porg.m3.SetupPaddingModifier,
- R.drawable.ic_warning
+ modifier = SetupPaddingModifier,
+ icon = R.drawable.ic_warning
)
RegularSetting(
title = "View encrypted preferences",
@@ -97,24 +98,10 @@ class DevOptMainActivity : ComponentActivity() {
title = "Demo mode",
body = "Launches Gugal in a demo mode, which obscures personal data with pre-configured examples. This can be useful for things like screenshots and promo material.",
onClick = {
- Global.demoMode = true
+ (application as GugalApplication).container.demoMode = true
startActivity(Intent(applicationContext, MainActivity::class.java))
},
)
-
- if (!resources.getBoolean(R.bool.isSimple)) {
- RegularSetting(
- title = "Toggle simple version",
- body = "Launches the app's main activity in simple mode. This imitates a simple build, with no news section or navbar. " +
- "To correctly test if a feature depends on a full build please use a simple build instead.",
- onClick = {
- val intent = Intent(applicationContext, MainActivity::class.java)
- intent.flags = Intent.FLAG_ACTIVITY_NEW_TASK
- intent.putExtra("simple", true)
- startActivity(intent)
- },
- )
- }
}
}
}
diff --git a/app/src/main/java/com/porg/gugal/devopt/DevOptSecurePrefsViewer.kt b/app/src/main/java/com/porg/gugal/devopt/DevOptSecurePrefsViewer.kt
index 4627a36ebb19f416e22d7d4c421aba2ccb3367f2..7ceaecfd140589e4b182c767cf06e860ef917258 100644
--- a/app/src/main/java/com/porg/gugal/devopt/DevOptSecurePrefsViewer.kt
+++ b/app/src/main/java/com/porg/gugal/devopt/DevOptSecurePrefsViewer.kt
@@ -1,7 +1,7 @@
/*
* DevOptSecurePrefsViewer.kt
* Gugal
- * Copyright (c) 2022 thegreatporg
+ * Copyright (c) 2022-2025 the Gugal maintainers
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
@@ -36,7 +36,7 @@ import androidx.compose.material3.Surface
import androidx.compose.ui.Modifier
import com.porg.gugal.Global.Companion.sharedPreferences
import com.porg.gugal.ui.theme.GugalTheme
-import com.porg.m3.settings.RegularSetting
+import tech.cataspect.m3x.settings.RegularSetting
class DevOptSecurePrefsViewer : ComponentActivity() {
@OptIn(
diff --git a/app/src/main/java/com/porg/gugal/devopt/DevOptSelectSerpActivity.kt b/app/src/main/java/com/porg/gugal/devopt/DevOptSelectSerpActivity.kt
index b10a1eef39c210ab705037e4e03fb491fcbe3af9..debd9c1685f58c41404e7304c3ac9e723d61ad02 100644
--- a/app/src/main/java/com/porg/gugal/devopt/DevOptSelectSerpActivity.kt
+++ b/app/src/main/java/com/porg/gugal/devopt/DevOptSelectSerpActivity.kt
@@ -1,7 +1,7 @@
/*
* SetupSelectSerpActivity.kt
* Gugal
- * Copyright (c) 2022 thegreatporg
+ * Copyright (c) 2022-2025 the Gugal maintainers
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
@@ -25,6 +25,7 @@ import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.padding
import androidx.compose.material3.*
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
@@ -37,9 +38,11 @@ import com.porg.gugal.BuildConfig
import com.porg.gugal.Global.Companion.setSerpProvider
import com.porg.gugal.R
import com.porg.gugal.devopt.providers.AlwaysFailingProvider
+import com.porg.gugal.devopt.providers.ResultDataTestProvider
+import com.porg.gugal.providers.DummySerp
import com.porg.gugal.ui.theme.GugalTheme
-import com.porg.m3.TwoButtons
-import com.porg.m3.settings.RadioSetting
+import tech.cataspect.m3x.TwoButtons
+import tech.cataspect.m3x.settings.RadioSetting
class DevOptSelectSerpActivity : ComponentActivity() {
@OptIn(
@@ -54,21 +57,23 @@ class DevOptSelectSerpActivity : ComponentActivity() {
Warning()
val _csp_id by currentSerpProviderId
- // A surface container using the 'background' color from the theme
- Surface(color = MaterialTheme.colorScheme.background) {
- TwoButtons(
- positiveAction = {
- setSerpProvider(_csp_id)
- saveSerp(_csp_id, true)
- finish()
- },
- positiveText = getString(R.string.btn_next),
- negativeAction = {
- finish()
- }
- )
+ Scaffold(
+ bottomBar = {
+ TwoButtons(
+ positiveAction = {
+ setSerpProvider(_csp_id)
+ saveSerp(_csp_id, true)
+ finish()
+ },
+ positiveText = getString(R.string.btn_next),
+ negativeAction = {
+ finish()
+ }
+ )
+ }
+ ) { padding ->
Column(
- modifier = Modifier.fillMaxSize()
+ modifier = Modifier.fillMaxSize().padding(padding)
) {
RadioSetting(
title = "Always fail",
@@ -78,6 +83,22 @@ class DevOptSelectSerpActivity : ComponentActivity() {
currentSerpProviderId.value = AlwaysFailingProvider.id
}
)
+ RadioSetting(
+ title = "Result data test",
+ body = "Shows results with all supported types.",
+ selected = _csp_id == ResultDataTestProvider.id,
+ onClick = {
+ currentSerpProviderId.value = ResultDataTestProvider.id
+ }
+ )
+ RadioSetting(
+ title = "Dummy",
+ body = null,
+ selected = _csp_id == DummySerp.id,
+ onClick = {
+ currentSerpProviderId.value = DummySerp.id
+ }
+ )
}
}
}
diff --git a/app/src/main/java/com/porg/gugal/devopt/providers/AlwaysFailingProvider.kt b/app/src/main/java/com/porg/gugal/devopt/providers/AlwaysFailingProvider.kt
index c0ba92b26d3bf6d9765e413da8a1d06fa364d712..d2771d17b6912a7e3e61873fc20eba30800e404c 100644
--- a/app/src/main/java/com/porg/gugal/devopt/providers/AlwaysFailingProvider.kt
+++ b/app/src/main/java/com/porg/gugal/devopt/providers/AlwaysFailingProvider.kt
@@ -1,7 +1,7 @@
/*
* AlwaysFailingProvider.kt
* Gugal
- * Copyright (c) 2022 thegreatporg
+ * Copyright (c) 2022-2025 the Gugal maintainers
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
@@ -30,7 +30,8 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import com.android.volley.toolbox.JsonObjectRequest
import com.porg.gugal.R
-import com.porg.gugal.data.Result
+import com.porg.gugal.data.results.Result
+import com.porg.gugal.providers.Credential
import com.porg.gugal.providers.ProviderInfo
import com.porg.gugal.providers.SerpProvider
import com.porg.gugal.providers.responses.ErrorResponse
@@ -55,7 +56,8 @@ class AlwaysFailingProvider: SerpProvider {
override fun ConfigComposable(
modifier: Modifier,
enableNextButton: MutableState,
- context: Context
+ context: Context,
+ requestCredentials: (Array, (Map) -> Unit) -> Unit
) {
Column(
modifier = Modifier
diff --git a/app/src/main/java/com/porg/gugal/devopt/providers/ResultDataTestProvider.kt b/app/src/main/java/com/porg/gugal/devopt/providers/ResultDataTestProvider.kt
new file mode 100644
index 0000000000000000000000000000000000000000..13df2600f9aa46657619f099196d9f6861070019
--- /dev/null
+++ b/app/src/main/java/com/porg/gugal/devopt/providers/ResultDataTestProvider.kt
@@ -0,0 +1,126 @@
+/*
+ * ResultDataTestProvider.kt
+ * Gugal
+ * Copyright © 2025 the Gugal maintainers
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ */
+
+package com.porg.gugal.devopt.providers
+
+import android.content.Context
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.padding
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.MutableState
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.text.SpanStyle
+import androidx.compose.ui.text.buildAnnotatedString
+import androidx.compose.ui.text.font.FontStyle
+import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.text.style.TextDecoration
+import androidx.compose.ui.text.withStyle
+import androidx.compose.ui.unit.dp
+import com.android.volley.toolbox.JsonObjectRequest
+import com.porg.gugal.R
+import com.porg.gugal.data.results.Result
+import com.porg.gugal.providers.Credential
+import com.porg.gugal.providers.ProviderInfo
+import com.porg.gugal.providers.SerpProvider
+import com.porg.gugal.providers.responses.ErrorResponse
+
+class ResultDataTestProvider: SerpProvider {
+
+ override val id: String get() = Companion.id
+ override val providerInfo = ProviderInfo(
+ R.string.setting_search_title,
+ R.string.setting_search_title,
+ R.string.setting_search_title,
+ false
+ )
+
+ companion object {
+ val id: String = "debug-rdt"
+ }
+
+ @Composable
+ override fun ConfigComposable(
+ modifier: Modifier,
+ enableNextButton: MutableState,
+ context: Context,
+ requestCredentials: (Array, (Map) -> Unit) -> Unit
+ ) {
+ Column(
+ modifier = Modifier
+ .padding(all = 4.dp)
+ .then(modifier)
+ ) {
+ Text(
+ text = "This SERP provider will return results with various fields.",
+ modifier = Modifier.padding(all = 4.dp),
+ style = MaterialTheme.typography.bodyLarge
+ )
+ }
+ }
+
+ override fun search(
+ query: String,
+ setResults: (List) -> Unit,
+ setError: (ErrorResponse) -> Unit
+ ): JsonObjectRequest? {
+ setResults(
+ listOf(
+ Result(
+ "Result title",
+ "This is the body of this result",
+ "https://gugal.example.com",
+ "domain.gugal"
+ ),
+ Result(
+ "Title only result",
+ null,
+ "https://gugal.example.com",
+ "domain.gugal"
+ ),
+ Result(
+ "Annotated result",
+ null,
+ "https://gugal.example.com",
+ "domain.gugal",
+ bodyAnnotated = buildAnnotatedString {
+ append("All supported types: normal, ")
+ withStyle(style = SpanStyle(fontWeight = FontWeight.Bold)) {
+ append("bold")
+ }
+ append(", ")
+ withStyle(style = SpanStyle(fontStyle = FontStyle.Italic)) {
+ append("italic")
+ }
+ append(", ")
+ withStyle(style = SpanStyle(textDecoration = TextDecoration.LineThrough)) {
+ append("strike (line through)")
+ }
+ append(", ")
+ withStyle(style = SpanStyle(textDecoration = TextDecoration.Underline)) {
+ append("underline")
+ }
+ }
+ )
+ )
+ )
+ return null
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/com/porg/gugal/experimental/ExperimentalGugalFeatureApi.kt b/app/src/main/java/com/porg/gugal/experimental/ExperimentalGugalFeatureApi.kt
new file mode 100644
index 0000000000000000000000000000000000000000..4a8066e667af17405a20fe5d25bb2d0b36a46521
--- /dev/null
+++ b/app/src/main/java/com/porg/gugal/experimental/ExperimentalGugalFeatureApi.kt
@@ -0,0 +1,31 @@
+/*
+ * ExperimentalGugalFeatureApi.kt
+ * Gugal
+ * Copyright (c) 2024-2025 the Gugal maintainers
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ */
+
+package com.porg.gugal.experimental
+
+@RequiresOptIn(message = "This API is related to an experimental feature.")
+@Target(
+ AnnotationTarget.CLASS,
+ AnnotationTarget.FUNCTION,
+ AnnotationTarget.PROPERTY,
+ AnnotationTarget.FIELD,
+ AnnotationTarget.PROPERTY_GETTER,
+)
+@Retention(AnnotationRetention.BINARY)
+annotation class ExperimentalGugalFeatureApi
\ No newline at end of file
diff --git a/app/src/main/java/com/porg/gugal/experimental/ExperimentalSerpProviderApi.kt b/app/src/main/java/com/porg/gugal/experimental/ExperimentalSerpProviderApi.kt
new file mode 100644
index 0000000000000000000000000000000000000000..24006ded728a65125b9d65b6c405abf5a23d44c6
--- /dev/null
+++ b/app/src/main/java/com/porg/gugal/experimental/ExperimentalSerpProviderApi.kt
@@ -0,0 +1,32 @@
+/*
+ * ExperimentalSerpProviderApi.kt
+ * Gugal
+ * Copyright (c) 2024-2025 the Gugal maintainers
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ */
+
+package com.porg.gugal.experimental
+
+@RequiresOptIn(message = "This is an experimental SERP provider API.")
+@Target(
+ AnnotationTarget.CLASS,
+ AnnotationTarget.FUNCTION,
+ AnnotationTarget.PROPERTY,
+ AnnotationTarget.FIELD,
+ AnnotationTarget.PROPERTY_GETTER,
+)
+@Retention(AnnotationRetention.BINARY)
+annotation class ExperimentalSerpProviderApi
+
diff --git a/app/src/main/java/com/porg/gugal/experimental/ExperimentalSerpRepositoryApi.kt b/app/src/main/java/com/porg/gugal/experimental/ExperimentalSerpRepositoryApi.kt
new file mode 100644
index 0000000000000000000000000000000000000000..c826644dad7d00a88db32c116d0c58c11716a1a1
--- /dev/null
+++ b/app/src/main/java/com/porg/gugal/experimental/ExperimentalSerpRepositoryApi.kt
@@ -0,0 +1,32 @@
+/*
+ * ExperimentalSerpRepositoryApi.kt
+ * Gugal
+ * Copyright (c) 2024-2025 the Gugal maintainers
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ */
+
+package com.porg.gugal.experimental
+
+@RequiresOptIn(message = "This is an experimental SERP repository API.")
+@Target(
+ AnnotationTarget.CLASS,
+ AnnotationTarget.FUNCTION,
+ AnnotationTarget.PROPERTY,
+ AnnotationTarget.FIELD,
+ AnnotationTarget.PROPERTY_GETTER,
+)
+@Retention(AnnotationRetention.BINARY)
+annotation class ExperimentalSerpRepositoryApi
+
diff --git a/app/src/main/java/com/porg/gugal/news/Article.kt b/app/src/main/java/com/porg/gugal/news/Article.kt
index 2f7a68e84bef58b1057c99f35b87de6f8b88c0ee..1149ae2afe52258dea507893f065b7809a6c9091 100644
--- a/app/src/main/java/com/porg/gugal/news/Article.kt
+++ b/app/src/main/java/com/porg/gugal/news/Article.kt
@@ -1,7 +1,7 @@
/*
* Article.kt
* Gugal
- * Copyright (c) 2022 thegreatporg
+ * Copyright (c) 2022-2025 the Gugal maintainers
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
diff --git a/app/src/main/java/com/porg/gugal/news/NSViewModel.kt b/app/src/main/java/com/porg/gugal/news/NSViewModel.kt
index 7b4e374dde54d2a656826536898337bc13c3af52..d22afbd6fa17736985723f94db59c2356149a9c6 100644
--- a/app/src/main/java/com/porg/gugal/news/NSViewModel.kt
+++ b/app/src/main/java/com/porg/gugal/news/NSViewModel.kt
@@ -1,7 +1,7 @@
/*
* NSViewModel.kt
* Gugal
- * Copyright (c) 2023 thegreatporg
+ * Copyright (c) 2023-2025 the Gugal maintainers
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
@@ -25,8 +25,7 @@ import androidx.lifecycle.ViewModel
import com.android.volley.RequestQueue
import com.android.volley.toolbox.JsonObjectRequest
import com.android.volley.toolbox.Volley
-import com.porg.gugal.Global
-import com.porg.gugal.Global.Companion.demoModeEditorial
+import com.porg.gugal.DemoMode
import com.porg.gugal.SUGGESTED_FEED_LIST_URL
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
@@ -47,14 +46,14 @@ class NSViewModel: ViewModel() {
lateinit var q: RequestQueue
private set
- fun loadFeeds() {
+ fun loadFeeds(demoMode: Boolean) {
// Request a string response from the provided URL.
val stringRequest = JsonObjectRequest(
SUGGESTED_FEED_LIST_URL,
{ response ->
- if (Global.demoMode) {
- setFeeds(demoModeEditorial)
+ if (demoMode) {
+ setFeeds(DemoMode.editorial)
} else {
val items = response.getJSONArray("feeds")
val list: MutableList = mutableListOf()
diff --git a/app/src/main/java/com/porg/gugal/news/NewsPage.kt b/app/src/main/java/com/porg/gugal/news/NewsPage.kt
index 0f4b185ab76e8679973224279dd2c77999083ab0..a1522251bbc1f628e8578de1140a01425ddfc2a7 100644
--- a/app/src/main/java/com/porg/gugal/news/NewsPage.kt
+++ b/app/src/main/java/com/porg/gugal/news/NewsPage.kt
@@ -1,7 +1,7 @@
/*
* NewsPage.kt
* Gugal
- * Copyright (c) 2022 thegreatporg
+ * Copyright (c) 2022-2025 the Gugal maintainers
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
@@ -57,7 +57,7 @@ import java.util.Date
val dateFormat: SimpleDateFormat = SimpleDateFormat("EEE, MMM d, yyyy")
@Composable
-fun NewsPage(context: Context?, newsModel: NewsViewModel = viewModel()) {
+fun NewsPage(context: Context?, newsModel: NewsViewModel = viewModel(), demoMode: Boolean) {
val newsState by newsModel.uiState.collectAsState()
val queue = Volley.newRequestQueue(context)
newsModel.setQueue(queue)
@@ -68,7 +68,7 @@ fun NewsPage(context: Context?, newsModel: NewsViewModel = viewModel()) {
return
}
newsModel.setFeeds(feeds.toList())
- newsModel.loadArticles()
+ newsModel.loadArticles(demoMode)
}
Articles(newsState.articles, { newsState.loaded }, context = context)
diff --git a/app/src/main/java/com/porg/gugal/news/NewsSettingsActivity.kt b/app/src/main/java/com/porg/gugal/news/NewsSettingsActivity.kt
index fa8333e8793a501ad951781d8f4bd33a35b68228..dd7fbaf6e288575fb7a08b6f6a8906b2fe6f2c0d 100644
--- a/app/src/main/java/com/porg/gugal/news/NewsSettingsActivity.kt
+++ b/app/src/main/java/com/porg/gugal/news/NewsSettingsActivity.kt
@@ -1,7 +1,7 @@
/*
* NewsSettingsActivity.kt
* Gugal
- * Copyright (c) 2023 thegreatporg
+ * Copyright (c) 2023-2025 the Gugal maintainers
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
@@ -19,7 +19,6 @@
package com.porg.gugal.news
-import android.content.Context
import android.content.Intent
import android.os.Bundle
import androidx.activity.ComponentActivity
@@ -33,8 +32,16 @@ import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.ExperimentalMaterialApi
import androidx.compose.material.icons.Icons
-import androidx.compose.material.icons.filled.ArrowBack
-import androidx.compose.material3.*
+import androidx.compose.material.icons.automirrored.filled.ArrowBack
+import androidx.compose.material3.ExperimentalMaterial3Api
+import androidx.compose.material3.Icon
+import androidx.compose.material3.IconButton
+import androidx.compose.material3.LargeTopAppBar
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Scaffold
+import androidx.compose.material3.Surface
+import androidx.compose.material3.Text
+import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
@@ -42,25 +49,23 @@ import androidx.compose.ui.input.nestedscroll.nestedScroll
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
-import androidx.core.content.ContextCompat
import androidx.core.view.WindowCompat
import com.porg.gugal.Global
-import com.porg.gugal.MainActivity
import com.porg.gugal.NEWS_ISSUE_URL
import com.porg.gugal.R
import com.porg.gugal.open
import com.porg.gugal.setup.SetupNewsActivity
+import com.porg.gugal.ui.MainActivity
import com.porg.gugal.ui.theme.GugalTheme
-import com.porg.m3.MainSwitch
-import com.porg.m3.Tip
-import com.porg.m3.settings.RegularSetting
+import tech.cataspect.m3x.MainSwitch
+import tech.cataspect.m3x.Tip
+import tech.cataspect.m3x.settings.RegularSetting
@OptIn(ExperimentalMaterial3Api::class)
class NewsSettingsActivity: ComponentActivity() {
@OptIn(ExperimentalAnimationApi::class, ExperimentalMaterialApi::class)
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
- val that = this
WindowCompat.setDecorFitsSystemWindows(window, false)
setContent {
val scrollBehavior = TopAppBarDefaults.exitUntilCollapsedScrollBehavior()
@@ -82,8 +87,8 @@ class NewsSettingsActivity: ComponentActivity() {
navigationIcon = {
IconButton(onClick = { finish() }) {
Icon(
- imageVector = Icons.Filled.ArrowBack,
- contentDescription = "Go back",
+ imageVector = Icons.AutoMirrored.Filled.ArrowBack,
+ contentDescription = stringResource(id = R.string.btn_back),
)
}
},
@@ -113,10 +118,13 @@ class NewsSettingsActivity: ComponentActivity() {
"newsFeeds", null
)?.size ?: 0) == 0 /*&&*/|| t
) {
- startNewsSetup(that)
+ startNewsSetup()
} else {
// restart main activity
- startActivity(Intent(that, MainActivity::class.java))
+ startActivity(Intent(
+ this@NewsSettingsActivity,
+ MainActivity::class.java
+ ))
}
}
)
@@ -139,11 +147,11 @@ class NewsSettingsActivity: ComponentActivity() {
RegularSetting(
stringResource(R.string.setting_newsFeeds_title),
stringResource(R.string.setting_newsFeeds_desc),
- onClick = {startNewsSetup(that)})
+ onClick = {startNewsSetup()})
RegularSetting(
- R.string.setting_issue_title,
- R.string.setting_newsIssue_desc,
- onClick = { open(that, NEWS_ISSUE_URL) }
+ stringResource(R.string.setting_issue_title),
+ stringResource(R.string.setting_newsIssue_desc),
+ onClick = { open(this@NewsSettingsActivity, NEWS_ISSUE_URL) }
)
}
}
@@ -153,10 +161,10 @@ class NewsSettingsActivity: ComponentActivity() {
}
}
- fun startNewsSetup(that: Context) {
- val intent = Intent(that, SetupNewsActivity::class.java)
+ fun startNewsSetup() {
+ val intent = Intent(this, SetupNewsActivity::class.java)
intent.flags = Intent.FLAG_ACTIVITY_NEW_TASK
intent.putExtra("restartMain", true)
- ContextCompat.startActivity(that, intent, null)
+ this.startActivity(intent, null)
}
}
\ No newline at end of file
diff --git a/app/src/main/java/com/porg/gugal/providers/Credential.kt b/app/src/main/java/com/porg/gugal/providers/Credential.kt
new file mode 100644
index 0000000000000000000000000000000000000000..38eb54cbd2c25dfb7a8c0bc1d41e45c395bd12f5
--- /dev/null
+++ b/app/src/main/java/com/porg/gugal/providers/Credential.kt
@@ -0,0 +1,80 @@
+/*
+ * Credential.kt
+ * Gugal
+ * Copyright (c) 2024-2025 the Gugal maintainers
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ */
+
+package com.porg.gugal.providers
+
+import com.porg.gugal.R
+import com.porg.gugal.providers.cse.GoogleCseSerp
+
+/**
+ * A credential.
+ *
+ * @param id the internal ID of this credential.
+ * @param name the user-facing name of this credential (e.g. "Token" or "Username") as a string resource ID.
+ * @param hide if true, fields for this credential will be shown with hidden characters (like a password field).
+ */
+data class Credential(
+ val id: String,
+ val name: Int,
+ val hide: Boolean = false
+) {
+ companion object {
+ /**
+ * An API key.
+ */
+ val API_KEY = Credential("ak", R.string.credential_apikey, true)
+
+ /**
+ * A username.
+ */
+ val USERNAME = Credential("username", R.string.credential_username)
+
+ /**
+ * A password.
+ */
+ val PASSWORD = Credential("pass", R.string.credential_password, true)
+
+ /**
+ * An instance URL.
+ */
+ val INSTANCE_URL = Credential("url", R.string.credential_instance_url)
+
+ /**
+ * A dummy credential. **Do not use!**
+ */
+ val DUMMY = Credential("dummy", R.string.app_name)
+
+ /**
+ * A dummy hidden credential. **Do not use!**
+ */
+ val DUMMY_HIDDEN = Credential("dummy_hidden", R.string.dialog_theme_system, true)
+ }
+}
+
+fun getCredentialByID(id: String): Credential {
+ return when (id) {
+ "ak" -> Credential.API_KEY
+ "username" -> Credential.USERNAME
+ "pass" -> Credential.PASSWORD
+ "url" -> Credential.INSTANCE_URL
+ // Add your custom IDs here.
+ "cx" -> GoogleCseSerp.CX_CREDENTIAL
+ else -> Credential.DUMMY
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/com/porg/gugal/providers/CustomHeaderJsonRequest.kt b/app/src/main/java/com/porg/gugal/providers/CustomHeaderJsonRequest.kt
new file mode 100644
index 0000000000000000000000000000000000000000..6c2e46b5f266cdf6da766fe4eedf05f15a65e886
--- /dev/null
+++ b/app/src/main/java/com/porg/gugal/providers/CustomHeaderJsonRequest.kt
@@ -0,0 +1,44 @@
+/*
+ * CustomHeaderRequest.kt
+ * Gugal
+ * Copyright (c) 2024-2025 the Gugal maintainers
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ */
+
+package com.porg.gugal.providers
+
+import android.util.Log
+import com.android.volley.toolbox.JsonObjectRequest
+
+class CustomHeaderJsonRequest(
+ method: Int,
+ url: String,
+ jsonRequest: org.json.JSONObject?,
+ listener: com.android.volley.Response.Listener,
+ errorListener: com.android.volley.Response.ErrorListener?,
+ requestHeaders: MutableMap? = null
+): JsonObjectRequest(method, url, jsonRequest, listener, errorListener) {
+
+ private var reqHdrs: MutableMap?
+
+ init {
+ reqHdrs = requestHeaders
+ }
+
+ override fun getHeaders(): MutableMap? {
+ Log.d("gugal", "headers requested!")
+ return reqHdrs
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/com/porg/gugal/providers/DummySerp.kt b/app/src/main/java/com/porg/gugal/providers/DummySerp.kt
index 52ad6bb1a4d08b2aab620f13ccb6648045be57b8..41be1232d74e77f8534d8ce22bb18a93917780c7 100644
--- a/app/src/main/java/com/porg/gugal/providers/DummySerp.kt
+++ b/app/src/main/java/com/porg/gugal/providers/DummySerp.kt
@@ -1,7 +1,7 @@
/*
* DummySerp.kt
* Gugal
- * Copyright (c) 2021 thegreatporg
+ * Copyright (c) 2021-2025 the Gugal maintainers
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
@@ -21,14 +21,14 @@ package com.porg.gugal.providers
import android.content.Context
import androidx.compose.foundation.layout.padding
-import androidx.compose.material.Text
+import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.MutableState
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import com.android.volley.toolbox.JsonObjectRequest
import com.porg.gugal.R
-import com.porg.gugal.data.Result
+import com.porg.gugal.data.results.Result
import com.porg.gugal.providers.responses.ErrorResponse
/**
@@ -49,7 +49,8 @@ class DummySerp: SerpProvider {
override fun ConfigComposable(
modifier: Modifier,
enableNextButton: MutableState,
- context: Context
+ context: Context,
+ requestCredentials: (Array, (Map) -> Unit) -> Unit
) {
Text(
text = "This is a dummy provider. Please change the provider to search the web.",
@@ -57,12 +58,12 @@ class DummySerp: SerpProvider {
)
}
- override fun getSensitiveCredentials(): Map {
- return mapOf("token" to "123456789abcdef")
+ override fun getSensitiveCredentials(): Map {
+ return mapOf(Credential.DUMMY to "123456789abcdef")
}
- override fun useSensitiveCredentials(credentials: Map) {
- token = credentials[token]!!
+ override fun useSensitiveCredentials(credentials: Map) {
+ token = credentials[Credential.DUMMY]!!
}
override fun search(
diff --git a/app/src/main/java/com/porg/gugal/providers/ProviderInfo.kt b/app/src/main/java/com/porg/gugal/providers/ProviderInfo.kt
index 4d680b425e93e17380ac805c187f9045b3a3f7eb..7f067bd0597b8f7fb0c319a496a767159303570c 100644
--- a/app/src/main/java/com/porg/gugal/providers/ProviderInfo.kt
+++ b/app/src/main/java/com/porg/gugal/providers/ProviderInfo.kt
@@ -1,7 +1,7 @@
/*
* ProviderInfo.kt
* Gugal
- * Copyright (c) 2021 thegreatporg
+ * Copyright (c) 2021-2025 the Gugal maintainers
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
diff --git a/app/src/main/java/com/porg/gugal/providers/SerpProvider.kt b/app/src/main/java/com/porg/gugal/providers/SerpProvider.kt
index 7a9a0695c743db357bd2600c2700e0c4e7fcd69a..2326738b4b91dab14d04954fc1808edb8a998eb7 100644
--- a/app/src/main/java/com/porg/gugal/providers/SerpProvider.kt
+++ b/app/src/main/java/com/porg/gugal/providers/SerpProvider.kt
@@ -1,7 +1,7 @@
/*
* SerpProvider.kt
* Gugal
- * Copyright (c) 2021 thegreatporg
+ * Copyright (c) 2021-2025 the Gugal maintainers
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
@@ -24,7 +24,7 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.MutableState
import androidx.compose.ui.Modifier
import com.android.volley.toolbox.JsonObjectRequest
-import com.porg.gugal.data.Result
+import com.porg.gugal.data.results.Result
import com.porg.gugal.providers.responses.ErrorResponse
/**
@@ -41,8 +41,17 @@ interface SerpProvider {
* @param modifier provided by the app and should be applied to the composable's root view
* (e.g. using [Modifier.then()]).
* @param enableNextButton provided by the app, enables the Next button in the setup wizard if true.
+ * @param context the context of the activity this composable is hosted in, provided by the app.
+ * @param requestCredentials provided by the app, call if you need extra authentication (such as a
+ * token or username/password), passing in the required credentials and an action to do when
+ * the request dialog is closed.
*/
- @Composable fun ConfigComposable(modifier: Modifier, enableNextButton: MutableState, context: Context) {}
+ @Composable fun ConfigComposable(
+ modifier: Modifier,
+ enableNextButton: MutableState,
+ context: Context,
+ requestCredentials: (Array, ((Map) -> Unit)) -> Unit
+ ) {}
/**
* Perform a web search for the provided query and add results to the provided list.
@@ -58,17 +67,17 @@ interface SerpProvider {
): JsonObjectRequest? {return null}
/**
- * Get any sensitive credentials, e.g. API keys and passwords.
+ * Get the values of any sensitive credentials, e.g. API keys and passwords.
*/
- fun getSensitiveCredentials(): Map {
- return mapOf("key" to "value")
+ fun getSensitiveCredentials(): Map {
+ return mapOf(Credential.DUMMY to "value")
}
/**
- * Get the names of any sensitive credential keys, e.g. API keys and passwords.
+ * Get any sensitive credential keys, e.g. API keys and passwords.
*/
- fun getSensitiveCredentialNames(): Array {
- return arrayOf("key")
+ fun getSensitiveCredentialNames(): Array {
+ return arrayOf(Credential.DUMMY)
}
/**
@@ -76,7 +85,7 @@ interface SerpProvider {
*
* @param credentials the loaded sensitive credentials.
*/
- fun useSensitiveCredentials(credentials: Map) {}
+ fun useSensitiveCredentials(credentials: Map) {}
companion object
diff --git a/app/src/main/java/com/porg/gugal/providers/cse/GoogleCseSerp.kt b/app/src/main/java/com/porg/gugal/providers/cse/GoogleCseSerp.kt
index 79c7325b604d1799eebb22b0d738f36784adb8cb..8ad717de2e78c5fda5cfe017df62cc019ba4efb9 100644
--- a/app/src/main/java/com/porg/gugal/providers/cse/GoogleCseSerp.kt
+++ b/app/src/main/java/com/porg/gugal/providers/cse/GoogleCseSerp.kt
@@ -1,7 +1,7 @@
/*
* GoogleCseSerp.kt
* Gugal
- * Copyright (c) 2021 thegreatporg
+ * Copyright (c) 2021-2025 the Gugal maintainers
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
@@ -41,7 +41,8 @@ import androidx.compose.ui.unit.dp
import com.android.volley.Request
import com.android.volley.toolbox.JsonObjectRequest
import com.porg.gugal.R
-import com.porg.gugal.data.Result
+import com.porg.gugal.data.results.Result
+import com.porg.gugal.providers.Credential
import com.porg.gugal.providers.ProviderInfo
import com.porg.gugal.providers.SerpProvider
import com.porg.gugal.providers.exceptions.InvalidCredentialException
@@ -57,7 +58,8 @@ class GoogleCseSerp: SerpProvider {
override fun ConfigComposable(
modifier: Modifier,
enableNextButton: MutableState,
- context: Context
+ context: Context,
+ requestCredentials: (Array, (Map) -> Unit) -> Unit
) {
val _cx = remember { mutableStateOf(TextFieldValue()) }
val _ak = remember { mutableStateOf(TextFieldValue()) }
@@ -97,7 +99,7 @@ class GoogleCseSerp: SerpProvider {
style = MaterialTheme.typography.bodyLarge
)
TextField(
- placeholder = { Text(text = stringResource(R.string.serp_gcse_apiKey)) },
+ placeholder = { Text(text = stringResource(R.string.credential_apikey)) },
value = _ak.value,
modifier = Modifier
.padding(all = 4.dp)
@@ -126,18 +128,18 @@ class GoogleCseSerp: SerpProvider {
}
}
- override fun getSensitiveCredentials(): Map {
- return mapOf("ak" to apikey, "cx" to cx)
+ override fun getSensitiveCredentials(): Map {
+ return mapOf(Credential.API_KEY to apikey, CX_CREDENTIAL to cx)
}
- override fun useSensitiveCredentials(credentials: Map) {
- if (!credentials.containsKey("cx") || !credentials.containsKey("ak")) throw InvalidCredentialException()
- cx = credentials["cx"]!!
- apikey = credentials["ak"]!!
+ override fun useSensitiveCredentials(credentials: Map) {
+ if (!credentials.containsKey(Credential.API_KEY) || !credentials.containsKey(CX_CREDENTIAL)) throw InvalidCredentialException()
+ cx = credentials[CX_CREDENTIAL]!!
+ apikey = credentials[Credential.API_KEY]!!
}
- override fun getSensitiveCredentialNames(): Array {
- return arrayOf("cx", "ak")
+ override fun getSensitiveCredentialNames(): Array {
+ return arrayOf(CX_CREDENTIAL, Credential.API_KEY)
}
override fun search(
@@ -208,5 +210,7 @@ class GoogleCseSerp: SerpProvider {
R.string.serp_gcse_desc,
R.string.serp_gcse_sb,
true)
+
+ val CX_CREDENTIAL = Credential("cx", R.string.serp_gcse_cx)
}
}
\ No newline at end of file
diff --git a/app/src/main/java/com/porg/gugal/providers/exceptions/InvalidCredentialException.kt b/app/src/main/java/com/porg/gugal/providers/exceptions/InvalidCredentialException.kt
index c891d8402b46f0ccf80d029498bfbc8c94986c3d..311da08d3e6d1a7081acb2179641eb2fae18bcd3 100644
--- a/app/src/main/java/com/porg/gugal/providers/exceptions/InvalidCredentialException.kt
+++ b/app/src/main/java/com/porg/gugal/providers/exceptions/InvalidCredentialException.kt
@@ -1,7 +1,7 @@
/*
* InvalidCredentialException.kt
* Gugal
- * Copyright (c) 2022 thegreatporg
+ * Copyright (c) 2022-2025 the Gugal maintainers
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
@@ -22,5 +22,4 @@ package com.porg.gugal.providers.exceptions
/**
* Thrown when the credentials provided by the user are invalid.
*/
-@Deprecated("Deprecated in favor of error responses; please use InvalidCredentialResponse instead")
class InvalidCredentialException(): SerpProviderException("Invalid credentials.")
\ No newline at end of file
diff --git a/app/src/main/java/com/porg/gugal/providers/exceptions/SearchException.kt b/app/src/main/java/com/porg/gugal/providers/exceptions/SearchException.kt
index fba24d0e96ebbe67245b0f743507f0686b597548..2f776faec56c54101051fcf4b7a3dc447d584a56 100644
--- a/app/src/main/java/com/porg/gugal/providers/exceptions/SearchException.kt
+++ b/app/src/main/java/com/porg/gugal/providers/exceptions/SearchException.kt
@@ -1,7 +1,7 @@
/*
* SearchException.kt
* Gugal
- * Copyright (c) 2022 thegreatporg
+ * Copyright (c) 2022-2025 the Gugal maintainers
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
@@ -22,7 +22,4 @@ package com.porg.gugal.providers.exceptions
/**
* Thrown when a search can't be performed.
*/
-@Deprecated("Deprecated in favor of error responses. " +
- "If used to indicate no results use NoResultsResponse; otherwise use a " +
- "regular ErrorResponse.")
class SearchException(message:String): SerpProviderException(message)
\ No newline at end of file
diff --git a/app/src/main/java/com/porg/gugal/providers/exceptions/SerpProviderException.kt b/app/src/main/java/com/porg/gugal/providers/exceptions/SerpProviderException.kt
index 946534d95c4257810b193eebb2371573392380ce..c887aade96df2f054951457ad662e8dce45e792c 100644
--- a/app/src/main/java/com/porg/gugal/providers/exceptions/SerpProviderException.kt
+++ b/app/src/main/java/com/porg/gugal/providers/exceptions/SerpProviderException.kt
@@ -1,7 +1,7 @@
/*
* SerpProviderException.kt
* Gugal
- * Copyright (c) 2022 thegreatporg
+ * Copyright (c) 2022-2025 the Gugal maintainers
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
@@ -22,5 +22,4 @@ package com.porg.gugal.providers.exceptions
/**
* Thrown when an error happens with the SERP provider. This exception is generic and more specific ones exist.
*/
-@Deprecated("Deprecated in favor of error responses; please use ErrorResponse or one of its overrides instead")
open class SerpProviderException(message:String): Exception(message)
\ No newline at end of file
diff --git a/app/src/main/java/com/porg/gugal/providers/experimental/AsyncSerpProvider.kt b/app/src/main/java/com/porg/gugal/providers/experimental/AsyncSerpProvider.kt
new file mode 100644
index 0000000000000000000000000000000000000000..da5bfd0d7a9037460f87875e3e0746601cf13f79
--- /dev/null
+++ b/app/src/main/java/com/porg/gugal/providers/experimental/AsyncSerpProvider.kt
@@ -0,0 +1,104 @@
+/*
+ * AsyncSerpProvider.kt
+ * Gugal
+ * Copyright (c) 2024-2025 the Gugal maintainers
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ */
+
+package com.porg.gugal.providers.experimental
+
+import android.content.Context
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Modifier
+import com.porg.gugal.data.results.Result
+import com.porg.gugal.experimental.ExperimentalSerpProviderApi
+import com.porg.gugal.providers.Credential
+import com.porg.gugal.providers.ProviderInfo
+import com.porg.gugal.providers.SerpProvider
+import com.porg.gugal.providers.exceptions.InvalidCredentialException
+import com.porg.gugal.providers.exceptions.SearchException
+import com.porg.gugal.providers.exceptions.SerpProviderException
+
+/**
+ * Experimental rewrite of [SerpProvider] based on async development practices.
+ */
+@ExperimentalSerpProviderApi
+interface AsyncSerpProvider {
+
+ /**
+ * A Composable which provides options to set up the SERP provider
+ * (e.g. input fields for any required API keys or username and password fields).
+ *
+ * Shown in the *Set up search* page of the setup wizard.
+ *
+ * @param modifier provided by the app and should be applied to the composable's root view
+ * (e.g. using [Modifier.then()]).
+ * @param toggleNextButton provided by the app, enables the Next button in the setup wizard if true.
+ * @param context the context of the activity this composable is hosted in, provided by the app.
+ * @param requestCredentials provided by the app, call if you need extra authentication (such as a
+ * token or username/password), passing in the required credentials and an action to do when
+ * the request dialog is closed.
+ */
+ @Composable
+ fun ConfigComposable(
+ modifier: Modifier,
+ toggleNextButton: (Boolean) -> Unit,
+ context: Context,
+ requestCredentials: (Array, ((Map) -> Unit)) -> Unit
+ ) {}
+
+ /**
+ * Perform a web search for the provided query and return the results.
+ *
+ * @param query the query to search for.
+ * @return a list of [Result]s.
+ * @throws InvalidCredentialException if the provided credentials are invalid.
+ * @throws SearchException when a search can't be performed.
+ * @throws SerpProviderException when another error occurs with the SERP provider.
+ */
+ suspend fun search(query: String): List
+
+ /**
+ * Get the values of any sensitive credentials, e.g. API keys and passwords.
+ */
+ fun getSensitiveCredentialValues(): Map {
+ return mapOf(Credential.DUMMY to "value")
+ }
+
+ /**
+ * Get any sensitive credential keys.
+ */
+ fun getSensitiveCredentials(): Array {
+ return arrayOf(Credential.DUMMY)
+ }
+
+ /**
+ * Gets called by the app after it loads the user's sensitive credentials (e.g. API keys and passwords) for this provider.
+ *
+ * @param credentials the loaded sensitive credentials.
+ */
+ fun useSensitiveCredentials(credentials: Map) {}
+
+ /**
+ * A unique ID that identifies this provider.
+ */
+ val id: String get() = ""
+
+ /**
+ * Information about this provider, like the name and description.
+ */
+ val providerInfo: ProviderInfo?
+}
+
diff --git a/app/src/main/java/com/porg/gugal/providers/responses/ErrorResponse.kt b/app/src/main/java/com/porg/gugal/providers/responses/ErrorResponse.kt
index 791190845c4532a311446a34756853b02af0ba7c..4992ceaabfcb5e83d9cdbbc9d7294cf6475a88eb 100644
--- a/app/src/main/java/com/porg/gugal/providers/responses/ErrorResponse.kt
+++ b/app/src/main/java/com/porg/gugal/providers/responses/ErrorResponse.kt
@@ -1,7 +1,7 @@
/*
* ErrorResponse.kt
* Gugal
- * Copyright (c) 2022 thegreatporg
+ * Copyright (c) 2022-2025 the Gugal maintainers
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
@@ -25,5 +25,20 @@ package com.porg.gugal.providers.responses
* @param errorCode an error code.
*/
open class ErrorResponse(val body: String, val errorCode: String) {
+ companion object {
+ /**
+ * The error code returned by an [InvalidCredentialResponse].
+ */
+ const val ERROR_CODE_INVALID_CREDENTIALS: String = "_ICR"
+ /**
+ * The error code returned by a [RateLimitedResponse].
+ */
+ const val ERROR_CODE_RATE_LIMITED: String = "_RLR"
+
+ /**
+ * The error code returned by a [NoResultsResponse].
+ */
+ const val ERROR_CODE_NO_RESULTS: String = "_NRR"
+ }
}
\ No newline at end of file
diff --git a/app/src/main/java/com/porg/gugal/providers/responses/InvalidCredentialResponse.kt b/app/src/main/java/com/porg/gugal/providers/responses/InvalidCredentialResponse.kt
index cfe274db196bd8143868b0f704bcd00324fde7da..a7305a2da4110e24f4c6ca9f1d5c13fbda00ee9a 100644
--- a/app/src/main/java/com/porg/gugal/providers/responses/InvalidCredentialResponse.kt
+++ b/app/src/main/java/com/porg/gugal/providers/responses/InvalidCredentialResponse.kt
@@ -1,7 +1,7 @@
/*
* InvalidCredentialResponse.kt
* Gugal
- * Copyright (c) 2022 thegreatporg
+ * Copyright (c) 2022-2025 the Gugal maintainers
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
@@ -24,4 +24,7 @@ package com.porg.gugal.providers.responses
*
* If Gugal receives this response it will show the provider's configuration page.
*/
-class InvalidCredentialResponse: ErrorResponse("_InvalidCredentialResp", "_ICR")
\ No newline at end of file
+class InvalidCredentialResponse: ErrorResponse(
+ body = "_InvalidCredentialResp",
+ errorCode = ERROR_CODE_INVALID_CREDENTIALS
+)
\ No newline at end of file
diff --git a/app/src/main/java/com/porg/gugal/providers/responses/NoResultsResponse.kt b/app/src/main/java/com/porg/gugal/providers/responses/NoResultsResponse.kt
index 1043c48c981bf57271ee042e35ef181f3839db84..e8977417641862483369cb29949e814d3b32b23f 100644
--- a/app/src/main/java/com/porg/gugal/providers/responses/NoResultsResponse.kt
+++ b/app/src/main/java/com/porg/gugal/providers/responses/NoResultsResponse.kt
@@ -1,7 +1,7 @@
/*
* NoResultsResponse.kt
* Gugal
- * Copyright (c) 2022 thegreatporg
+ * Copyright (c) 2022-2025 the Gugal maintainers
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
@@ -24,4 +24,7 @@ package com.porg.gugal.providers.responses
*
* If Gugal receives this response it will show a "No results" message.
*/
-class NoResultsResponse: ErrorResponse("_NoResultsResp", "_NRR")
\ No newline at end of file
+class NoResultsResponse: ErrorResponse(
+ body = "_NoResultsResp",
+ errorCode = ERROR_CODE_NO_RESULTS
+)
\ No newline at end of file
diff --git a/app/src/main/java/com/porg/gugal/providers/responses/RateLimitedResponse.kt b/app/src/main/java/com/porg/gugal/providers/responses/RateLimitedResponse.kt
index 8ef4877cb1fbf4e32f627334b97631e8ad70e26c..7c2a769ee95ae5f2e9e3f47f5456fdc3b5929b11 100644
--- a/app/src/main/java/com/porg/gugal/providers/responses/RateLimitedResponse.kt
+++ b/app/src/main/java/com/porg/gugal/providers/responses/RateLimitedResponse.kt
@@ -1,7 +1,7 @@
/*
* RateLimitedResponse.kt
* Gugal
- * Copyright (c) 2025 thegreatporg
+ * Copyright © 2025 the Gugal maintainers
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
@@ -24,4 +24,7 @@ package com.porg.gugal.providers.responses
*
* If Gugal receives this response it will ask the user to try searching later.
*/
-class RateLimitedResponse: ErrorResponse("_RateLimitedResponse", "_RLR")
\ No newline at end of file
+class RateLimitedResponse: ErrorResponse(
+ body = "_RateLimitedResponse",
+ errorCode = ERROR_CODE_RATE_LIMITED
+)
\ No newline at end of file
diff --git a/app/src/main/java/com/porg/gugal/providers/searx/SearXSerp.kt b/app/src/main/java/com/porg/gugal/providers/searx/SearXSerp.kt
index 4b17be9adca3f5e18823d68e788423e0ae3cb458..bf370704748c5a13f42a5c679492b5815ee323f8 100644
--- a/app/src/main/java/com/porg/gugal/providers/searx/SearXSerp.kt
+++ b/app/src/main/java/com/porg/gugal/providers/searx/SearXSerp.kt
@@ -1,7 +1,7 @@
/*
* SearXSerp.kt
* Gugal
- * Copyright (c) 2022 thegreatporg
+ * Copyright (c) 2022-2025 the Gugal maintainers
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
@@ -20,6 +20,7 @@
package com.porg.gugal.providers.searx
import android.content.Context
+import android.util.Base64
import android.util.Log
import android.webkit.URLUtil
import androidx.compose.foundation.layout.Column
@@ -27,12 +28,7 @@ import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions
-import androidx.compose.material3.Button
-import androidx.compose.material3.ExperimentalMaterial3Api
-import androidx.compose.material3.LinearProgressIndicator
-import androidx.compose.material3.MaterialTheme
-import androidx.compose.material3.Text
-import androidx.compose.material3.TextField
+import androidx.compose.material3.*
import androidx.compose.runtime.Composable
import androidx.compose.runtime.MutableState
import androidx.compose.runtime.mutableStateOf
@@ -44,12 +40,15 @@ import androidx.compose.ui.text.input.TextFieldValue
import androidx.compose.ui.text.intl.Locale
import androidx.compose.ui.unit.dp
import com.android.volley.NetworkError
+import com.android.volley.Request
import com.android.volley.RequestQueue
import com.android.volley.toolbox.JsonObjectRequest
import com.android.volley.toolbox.Volley
import com.porg.gugal.BuildConfig
import com.porg.gugal.R
-import com.porg.gugal.data.Result
+import com.porg.gugal.data.results.Result
+import com.porg.gugal.providers.Credential
+import com.porg.gugal.providers.CustomHeaderJsonRequest
import com.porg.gugal.providers.ProviderInfo
import com.porg.gugal.providers.SerpProvider
import com.porg.gugal.providers.exceptions.InvalidCredentialException
@@ -63,6 +62,8 @@ import java.net.URL
class SearXSerp: SerpProvider {
private var url = ""
+ private var username = ""
+ private var password = ""
private val connStateDefault = 0
private val connStateFail = 1
@@ -74,8 +75,15 @@ class SearXSerp: SerpProvider {
override fun ConfigComposable(
modifier: Modifier,
enableNextButton: MutableState,
- context: Context
+ context: Context,
+ requestCredentials: (Array, (Map) -> Unit) -> Unit
) {
+ requestCredentials(
+ arrayOf(
+ Credential.USERNAME,
+ Credential.PASSWORD
+ )
+ ) {}
val _url = remember { mutableStateOf(TextFieldValue()) }
val _connTest = remember { mutableStateOf(connStateDefault) }
Column(
@@ -109,7 +117,7 @@ class SearXSerp: SerpProvider {
style = MaterialTheme.typography.bodyLarge
)
Button(
- onClick = {connTest(_connTest, context)},
+ onClick = {connTest(_connTest, context, requestCredentials)},
enabled = _url.value.text.isNotEmpty(),
modifier = Modifier
.padding(all = 4.dp)
@@ -131,6 +139,18 @@ class SearXSerp: SerpProvider {
.padding(all = 4.dp)
.fillMaxWidth()
)
+ else -> {
+ if (_connTest.value > 0) {
+ Text(
+ String.format(
+ locale = Locale.current.platformLocale,
+ stringResource(R.string.serp_searx_connTest_http),
+ _connTest.value
+ ),
+ color = MaterialTheme.colorScheme.error
+ )
+ }
+ }
}
// Update the Next button based on the connectivity test state
@@ -138,10 +158,14 @@ class SearXSerp: SerpProvider {
}
}
- fun connTest(state: MutableState, context: Context) {
+ private fun connTest(
+ state: MutableState,
+ context: Context,
+ requestCredentials: (Array, (Map) -> Unit) -> Unit
+ ) {
try {
// Create a JSON request, searching the instance for "gugal app"
- val jr = object : JsonObjectRequest(
+ val jr = object: JsonObjectRequest(
Method.GET, makeURL("gugal+app"), null,
{ response ->
// If there is a results key, check it it's empty
@@ -155,12 +179,38 @@ class SearXSerp: SerpProvider {
if (!result) state.value = connStateFail
else state.value = connStatePass
},
- // Any HTTP errors fail the connectivity test
- // This is done because SearXNG blocks API access by making /search return HTTP 403
{
- state.value = connStateFail
- Log.d("gugal", "connTest failed!")
- Log.d("gugal", it.message ?: "no message")
+ // A null NetworkResponse seems to indicate that the error occurred in a part of Volley
+ // unrelated to networking (e.g. JSON parsing), which would indicate that something is
+ // likely wrong with the instance, not Gugal itself (and if the issue _is_ with Gugal/Volley,
+ // the "Unsupported instance" error will hopefully get the user to report the issue)
+ if (it.networkResponse == null) {
+ state.value = connStateFail
+ Log.d("gugal", "connTest failed (no nr)!")
+ Log.d("gugal", it.message ?: "no message")
+ } else {
+ // Error code 401 (unauthorized) means that the server is using some form of
+ // HTTP authentication - request the credentials
+ if (it.networkResponse.statusCode == 401) {
+ requestCredentials(
+ arrayOf(
+ Credential.USERNAME,
+ Credential.PASSWORD
+ )
+ ) { creds ->
+ username = creds[Credential.USERNAME] ?: ""
+ password = creds[Credential.PASSWORD] ?: ""
+ connTest(state, context, requestCredentials)
+ }
+ }
+ // Any other HTTP errors fail the connectivity test
+ // This is done because SearXNG blocks API access by making /search return HTTP 403
+ else {
+ state.value = it.networkResponse.statusCode
+ Log.d("gugal", "connTest failed!")
+ Log.d("gugal", it.message ?: "no message")
+ }
+ }
}
) {
override fun getHeaders(): MutableMap {
@@ -186,20 +236,25 @@ class SearXSerp: SerpProvider {
headers["Accept"] = "application/json;q=1.0,text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8"
headers["Accept-Encoding"] = "gzip"
headers["Accept-Language"] = Locale.current.platformLocale.language + ";q=0.5"
+ // Add authorization if present
+ if (username != "" && password != "") {
+ headers["Authorization"] = "Basic " + createBasicAuthHeader(username, password)
+ }
return headers
}
-
- override fun getSensitiveCredentials(): Map {
- return mapOf("url" to url)
+ override fun getSensitiveCredentials(): Map {
+ return mapOf(Credential.INSTANCE_URL to url, Credential.USERNAME to username, Credential.PASSWORD to password)
}
- override fun getSensitiveCredentialNames(): Array {
- return arrayOf("url")
+ override fun getSensitiveCredentialNames(): Array {
+ return arrayOf(Credential.INSTANCE_URL, Credential.USERNAME, Credential.PASSWORD)
}
- override fun useSensitiveCredentials(credentials: Map) {
- if (!credentials.containsKey("url")) throw InvalidCredentialException()
- url = credentials["url"]!!
+ override fun useSensitiveCredentials(credentials: Map) {
+ if (!credentials.containsKey(Credential.INSTANCE_URL)) throw InvalidCredentialException()
+ url = credentials[Credential.INSTANCE_URL]!!
+ if (credentials.containsKey(Credential.USERNAME)) username = credentials[Credential.USERNAME]!!
+ if (credentials.containsKey(Credential.PASSWORD)) password = credentials[Credential.PASSWORD]!!
}
private fun makeURL(query: String): String {
@@ -215,8 +270,8 @@ class SearXSerp: SerpProvider {
setError: (ErrorResponse) -> Unit
): JsonObjectRequest {
// Request a string response from the provided URL.
- return object : JsonObjectRequest(
- Method.GET, makeURL(query), null,
+ return CustomHeaderJsonRequest(
+ Request.Method.GET, makeURL(query), null,
{ response ->
val list: MutableList = mutableListOf()
val items = response.getJSONArray("results")
@@ -250,8 +305,11 @@ class SearXSerp: SerpProvider {
)
}
} else {
- if (error.networkResponse.statusCode == 429) setError(RateLimitedResponse())
- else {
+ Log.e("SearXSerp", error.message ?: "no msg")
+ // Detect rate limits with a separate response
+ if (error.networkResponse.statusCode == 429) {
+ setError(RateLimitedResponse())
+ } else {
error.message?.let {
val nr = error.networkResponse
setError(
@@ -261,12 +319,15 @@ class SearXSerp: SerpProvider {
}
}
}
- }
- ) {
- override fun getHeaders(): MutableMap {
- return getSearxHeaders()
- }
- }
+ },
+ getSearxHeaders()
+ )
+ }
+
+ private fun createBasicAuthHeader(username: String, password: String): String {
+ val secretKey = "$username:$password"
+ val tokenBytes = secretKey.toByteArray()
+ return Base64.encodeToString(tokenBytes, Base64.URL_SAFE or Base64.NO_PADDING)
}
override val providerInfo = Companion.providerInfo
diff --git a/app/src/main/java/com/porg/gugal/providers/stract/StractSerp.kt b/app/src/main/java/com/porg/gugal/providers/stract/StractSerp.kt
new file mode 100644
index 0000000000000000000000000000000000000000..63097ad5e1fe46700ee50b91b5d837dd47026136
--- /dev/null
+++ b/app/src/main/java/com/porg/gugal/providers/stract/StractSerp.kt
@@ -0,0 +1,148 @@
+/*
+ * StractSerp.kt
+ * Gugal
+ * Copyright (c) 2024 thegreatporg
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ */
+
+package com.porg.gugal.providers.stract
+
+import android.util.Log
+import androidx.compose.ui.text.AnnotatedString
+import androidx.compose.ui.text.SpanStyle
+import androidx.compose.ui.text.buildAnnotatedString
+import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.text.withStyle
+import com.android.volley.Request
+import com.android.volley.toolbox.JsonObjectRequest
+import com.porg.gugal.R
+import com.porg.gugal.data.results.Result
+import com.porg.gugal.providers.ProviderInfo
+import com.porg.gugal.providers.SerpProvider
+import com.porg.gugal.providers.responses.ErrorResponse
+import com.porg.gugal.providers.responses.InvalidCredentialResponse
+import com.porg.gugal.providers.responses.NoResultsResponse
+import org.json.JSONArray
+import org.json.JSONException
+import org.json.JSONObject
+import java.nio.charset.Charset
+
+
+class StractSerp: SerpProvider {
+
+ companion object {
+ val id: String = "57f3ed4a0d8848778a0df5070859888f-stract"
+ val providerInfo = ProviderInfo(
+ R.string.serp_stract_title,
+ R.string.serp_stract_desc,
+ R.string.serp_stract_title,
+ false
+ )
+ }
+ // Information about this provider, like the name and description.
+ override val providerInfo: ProviderInfo?
+ get() = Companion.providerInfo
+
+ fun parseSnippet(snippet: JSONArray): AnnotatedString? {
+ return buildAnnotatedString {
+ for (i in 0 until snippet.length()) {
+ val part: JSONObject = snippet.getJSONObject(i)
+
+ // error if the part is invalid just in case
+ if (!part.has("kind")) {
+ Log.w("gugal#stract", "Invalid snippet: $part")
+ return null
+ }
+
+ // get the snippet's kind and text
+ val kind = part.getString("kind")
+ val text = part.getString("text")
+ // if it is highlighted, add it with a bold style
+ if (kind == "highlighted") {
+ withStyle(style = SpanStyle(fontWeight = FontWeight.Bold)) {
+ append(text)
+ }
+ } else append(text)
+ }
+ }
+ }
+
+ override fun search(
+ query: String,
+ setResults: ((List) -> (Unit)),
+ setError: ((ErrorResponse) -> (Unit))
+ ): JsonObjectRequest? {
+ val jsonBody = JSONObject()
+
+ try {
+ jsonBody.put("query", query)
+ } catch (e: JSONException) {
+ e.printStackTrace()
+ }
+ // Request a string response from the provided URL.
+ return JsonObjectRequest(
+ Request.Method.POST,
+ "https://stract.com/beta/api/search",
+ jsonBody,
+ { response ->
+ Log.d("gugal#stract", jsonBody.toString(2))
+ Log.d("gugal#stract", response.toString(2))
+ // Send no results response if there are no results
+ if (!response.has("webpages")) setError(NoResultsResponse())
+ else {
+ val webpages = response.getJSONArray("webpages")
+ val list: MutableList = mutableListOf()
+ for (i in 0 until webpages.length()) {
+ val item: JSONObject = webpages.getJSONObject(i)
+ if (item.has("snippet")) {
+ val snippet = item.getJSONObject("snippet")
+ val snFrags = snippet.getJSONObject("text").getJSONArray("fragments")
+ list.add(
+ Result(
+ item.getString("title"), null,
+ item.getString("url"), item.getString("prettyUrl"),
+ bodyAnnotated = parseSnippet(snFrags)
+ )
+ )
+ } else
+ list.add(
+ Result(
+ item.getString("title"), null,
+ item.getString("link"), item.getString("displayLink")
+ )
+ )
+ }
+ setResults(list)
+ }
+ },
+ { error ->
+ // Retrieve body, if it exists
+ if (error.networkResponse.data != null) {
+ // Detect credential error and use an invalid credential response
+ val response = String(error.networkResponse.data, Charset.forName("UTF-8"))
+ setError(
+ if ("API_KEY_INVALID" in response
+ || "INVALID_ARGUMENT" in response
+ || "PERMISSION_DENIED" in response) InvalidCredentialResponse()
+ // Else use a regular error response
+ else ErrorResponse(response, error.networkResponse.statusCode.toString())
+ )
+ } else {
+ setError(ErrorResponse("General error", error.networkResponse.statusCode.toString()))
+ }
+ }
+ )
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/com/porg/gugal/setup/SetupConfigureSerpActivity.kt b/app/src/main/java/com/porg/gugal/setup/SetupConfigureSerpActivity.kt
index 2e75c9fa5c9c7cca1fc1b5a4255e653384eb1e76..19b7f6f0df219c9b03e49d5caebe2fefce87dc81 100644
--- a/app/src/main/java/com/porg/gugal/setup/SetupConfigureSerpActivity.kt
+++ b/app/src/main/java/com/porg/gugal/setup/SetupConfigureSerpActivity.kt
@@ -1,7 +1,7 @@
/*
* SetupConfigureSerpActivity.kt
* Gugal
- * Copyright (c) 2022 thegreatporg
+ * Copyright (c) 2022-2025 the Gugal maintainers
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
@@ -19,34 +19,65 @@
package com.porg.gugal.setup
-import android.content.ComponentName
import android.content.Context
import android.content.Intent
import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.lazy.LazyColumn
+import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
-import androidx.compose.material.icons.filled.ArrowBack
-import androidx.compose.material3.*
+import androidx.compose.material.icons.automirrored.filled.ArrowBack
+import androidx.compose.material3.Button
+import androidx.compose.material3.ExperimentalMaterial3Api
+import androidx.compose.material3.Icon
+import androidx.compose.material3.IconButton
+import androidx.compose.material3.LargeTopAppBar
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.ModalBottomSheet
+import androidx.compose.material3.OutlinedTextField
+import androidx.compose.material3.Scaffold
+import androidx.compose.material3.Surface
+import androidx.compose.material3.Text
+import androidx.compose.material3.TopAppBarDefaults
+import androidx.compose.material3.rememberModalBottomSheetState
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateMapOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
+import androidx.compose.runtime.saveable.rememberSaveable
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
+import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.input.nestedscroll.nestedScroll
+import androidx.compose.ui.res.painterResource
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.res.vectorResource
+import androidx.compose.ui.text.input.PasswordVisualTransformation
+import androidx.compose.ui.text.input.VisualTransformation
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.core.view.WindowCompat
+import androidx.lifecycle.viewmodel.compose.viewModel
import com.porg.gugal.BuildConfig
import com.porg.gugal.Global.Companion.serpProvider
import com.porg.gugal.Global.Companion.sharedPreferences
+import com.porg.gugal.GugalApplication
import com.porg.gugal.R
+import com.porg.gugal.providers.Credential
import com.porg.gugal.providers.SerpProvider
import com.porg.gugal.ui.theme.GugalTheme
-import com.porg.m3.TwoButtons
+import com.porg.gugal.viewmodel.ConfigureSerpViewModel
+import tech.cataspect.m3x.TwoButtons
class SetupConfigureSerpActivity : ComponentActivity() {
@@ -54,16 +85,26 @@ class SetupConfigureSerpActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
val ctx = this
- WindowCompat.setDecorFitsSystemWindows(window, false)
+ WindowCompat.setDecorFitsSystemWindows(window, true)
- // check if launched from settings
+ // find out where it was launched from
val launchedFromSettings = intent.getBooleanExtra("launchedFromSettings", false)
+ val launchedFromSearchError = intent.getBooleanExtra("launchedFromSearchError", false)
+ val launchedIndirectly = launchedFromSearchError || launchedFromSettings
setContent {
+ val viewModel: ConfigureSerpViewModel = viewModel()
+ val vmState by viewModel.uiState.collectAsState()
val showNext = remember { mutableStateOf(false) }
val sn by showNext
+ val credMap = remember { mutableStateMapOf() }
+
+ val bottomSheetState = rememberModalBottomSheetState(
+ skipPartiallyExpanded = false
+ )
+
val scrollBehavior = TopAppBarDefaults.exitUntilCollapsedScrollBehavior()
GugalTheme {
@@ -81,53 +122,166 @@ class SetupConfigureSerpActivity : ComponentActivity() {
navigationIcon = {
IconButton(onClick = { finish() }) {
Icon(
- imageVector = Icons.Filled.ArrowBack,
- contentDescription = "Go back",
+ imageVector = Icons.AutoMirrored.Filled.ArrowBack,
+ contentDescription = stringResource(id = R.string.btn_back),
)
}
},
scrollBehavior = scrollBehavior
)
},
+ bottomBar = {
+ TwoButtons(
+ positiveAction = {
+ saveSensitive(serpProvider, ctx, launchedIndirectly)
+ },
+ positiveText = getString(R.string.btn_save),
+ negativeAction = { finish() },
+ showNegative = !launchedIndirectly,
+ disablePositive = !sn
+ )
+ },
content = { innerPadding ->
Surface(color = MaterialTheme.colorScheme.background,
- modifier = Modifier.padding(innerPadding)
+ modifier = Modifier
+ .padding(innerPadding)
.verticalScroll(rememberScrollState())) {
serpProvider.ConfigComposable(
- modifier = Modifier.fillMaxSize().padding(horizontal = 16.dp, vertical = 8.dp),
+ modifier = Modifier
+ .fillMaxSize()
+ .padding(horizontal = 16.dp, vertical = 8.dp),
enableNextButton = showNext,
context = this
- )
+ ) { keys, action ->
+ viewModel.requestCredentials(keys, action)
+ }
}
- TwoButtons(
- positiveAction = { saveSensitive(serpProvider, ctx) },
- positiveText = getString(R.string.btn_save),
- negativeAction = { finish() },
- showNegative = !launchedFromSettings,
- disablePositive = !sn
- )
}
)
+
+ // Credential bottom sheet
+ if (vmState.showSheet) {
+ ModalBottomSheet(
+ onDismissRequest = { viewModel.hideSheet() },
+ sheetState = bottomSheetState,
+ ) {
+ LazyColumn(Modifier.fillMaxWidth(), horizontalAlignment = Alignment.CenterHorizontally) {
+ item {
+ Icon(
+ painter = painterResource(id = R.drawable.ic_user),
+ contentDescription = null,
+ modifier = Modifier.size(36.dp),
+ tint = MaterialTheme.colorScheme.primary
+ )
+ }
+ item {
+ Text(
+ text = getString(R.string.setup_p3_auth_title),
+ style = MaterialTheme.typography.headlineMedium,
+ modifier = Modifier.padding(16.dp)
+ )
+ }
+ item {
+ Text(getString(R.string.setup_p3_auth_description),
+ modifier = Modifier.padding(16.dp))
+ }
+ items(vmState.requestedCredentials) {
+ CredentialField(
+ credential = it,
+ value = credMap[it] ?: "",
+ onValueChanged = { newValue -> credMap[it] = newValue },
+ modifier = Modifier
+ .padding(horizontal = 16.dp)
+ .fillMaxWidth()
+ )
+ }
+ item {
+ Button(
+ onClick = {
+ viewModel.setCredentials(credMap)
+ viewModel.submitAndHideSheet()
+ },
+ modifier = Modifier.padding(bottom = 16.dp)
+ ) {
+ Text(stringResource(R.string.setup_p3_auth_btn))
+ }
+ }
+ }
+ }
+ }
}
}
}
- private fun saveSensitive(serpProvider: SerpProvider, ctx: Context) {
+ @Composable
+ fun CredentialField(
+ credential: Credential,
+ value: String,
+ onValueChanged: (String) -> Unit,
+ modifier: Modifier = Modifier
+ ) {
+ var passwordVisible by rememberSaveable { mutableStateOf(false) }
+ OutlinedTextField(
+ value = value,
+ onValueChange = onValueChanged,
+ label = {Text(getString(credential.name))},
+ singleLine = true,
+ visualTransformation =
+ // only toggle the password field if this is a credential that needs to be hidden
+ if (credential.hide) {
+ if (passwordVisible) VisualTransformation.None else PasswordVisualTransformation()
+ } else VisualTransformation.None,
+ isError = false,
+ trailingIcon = {
+ // only show a visibility button if this is a credential that needs to be hidden
+ if (credential.hide) {
+ val image = ImageVector.vectorResource(
+ if (passwordVisible) R.drawable.ic_visibility_off
+ else R.drawable.ic_visibility_on
+ )
+
+ // Please provide localized description for accessibility services
+ val description =
+ if (passwordVisible) "Hide password" else "Show password"
+
+ IconButton(onClick = {
+ passwordVisible = !passwordVisible
+ }) {
+ Icon(imageVector = image, description)
+ }
+ }
+ },
+ modifier = modifier
+ )
+ }
+
+ private fun saveSensitive(serpProvider: SerpProvider, ctx: Context, launchedIndirectly: Boolean) {
// Get the sensitive data
val sensitive = serpProvider.getSensitiveCredentials()
with (sharedPreferences.edit()) {
// Edit the user's shared preferences
for (i in sensitive) {
- this.putString("serp_${serpProvider.id}_${i.key}", i.value)
+ this.putString("serp_${serpProvider.id}_${i.key.id}", i.value)
}
// Save the last Gugal version to not show the changelog again
this.putInt("lastGugalVersion", BuildConfig.VERSION_CODE)
apply()
}
- val intent = Intent(ctx, SetupFOSSActivity::class.java)
- startActivity(intent)
+ // If this page was started indirectly (i.e. via settings or a search error),
+ // return instead of showing the next page
+ if (launchedIndirectly) {
+ finish()
+ } else {
+ startActivity(
+ if ((application as GugalApplication).container.experiments["gugalNewsSetup"]?.enabled == true) {
+ Intent(ctx, SetupExtraNewsActivity::class.java)
+ } else {
+ Intent(ctx, SetupFOSSActivity::class.java)
+ }
+ )
+ }
}
}
\ No newline at end of file
diff --git a/app/src/main/java/com/porg/gugal/setup/SetupExtraNewsActivity.kt b/app/src/main/java/com/porg/gugal/setup/SetupExtraNewsActivity.kt
new file mode 100644
index 0000000000000000000000000000000000000000..b840c09c42bc1f7a7fec9424fb6da31b145dbb3c
--- /dev/null
+++ b/app/src/main/java/com/porg/gugal/setup/SetupExtraNewsActivity.kt
@@ -0,0 +1,161 @@
+/*
+ * SetupExtraNewsActivity.kt
+ * Gugal
+ * Copyright © 2025 the Gugal maintainers
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ */
+
+package com.porg.gugal.setup
+
+import android.content.Intent
+import android.os.Bundle
+import androidx.activity.ComponentActivity
+import androidx.activity.compose.setContent
+import androidx.compose.animation.ExperimentalAnimationApi
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.rememberScrollState
+import androidx.compose.foundation.verticalScroll
+import androidx.compose.material.ExperimentalMaterialApi
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.automirrored.filled.ArrowBack
+import androidx.compose.material3.ExperimentalMaterial3Api
+import androidx.compose.material3.Icon
+import androidx.compose.material3.IconButton
+import androidx.compose.material3.LargeTopAppBar
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Scaffold
+import androidx.compose.material3.Surface
+import androidx.compose.material3.Text
+import androidx.compose.material3.TopAppBarDefaults
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.input.nestedscroll.nestedScroll
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.text.style.TextOverflow
+import androidx.compose.ui.unit.dp
+import androidx.core.view.WindowCompat
+import com.porg.gugal.Global
+import com.porg.gugal.R
+import com.porg.gugal.ui.MainActivity
+import com.porg.gugal.ui.theme.GugalTheme
+import tech.cataspect.m3x.MainSwitch
+import tech.cataspect.m3x.Tip
+
+class SetupExtraNewsActivity: ComponentActivity() {
+
+ @OptIn(ExperimentalAnimationApi::class, ExperimentalMaterialApi::class,
+ ExperimentalMaterial3Api::class
+ )
+ override fun onCreate(savedInstanceState: Bundle?) {
+ super.onCreate(savedInstanceState)
+ WindowCompat.setDecorFitsSystemWindows(window, false)
+ setContent {
+ val scrollBehavior = TopAppBarDefaults.exitUntilCollapsedScrollBehavior()
+ val mainSwitchChecked = remember {
+ mutableStateOf(
+ Global.sharedPreferences.getBoolean("newsPreview", false)
+ )
+ }
+ GugalTheme {
+ Scaffold(
+ modifier = Modifier.nestedScroll(scrollBehavior.nestedScrollConnection),
+ topBar = {
+ LargeTopAppBar(
+ title = {
+ Text(
+ getText(R.string.setup_extra_news_title).toString(),
+ maxLines = 1,
+ overflow = TextOverflow.Ellipsis
+ )
+ },
+ navigationIcon = {
+ IconButton(onClick = { finish() }) {
+ Icon(
+ imageVector = Icons.AutoMirrored.Filled.ArrowBack,
+ contentDescription = stringResource(id = R.string.btn_back),
+ )
+ }
+ },
+ scrollBehavior = scrollBehavior
+ )
+ },
+ content = { innerPadding ->
+ Surface(color = MaterialTheme.colorScheme.background) {
+ Column(modifier = Modifier
+ .padding(innerPadding)
+ .verticalScroll(rememberScrollState())
+ .fillMaxSize()
+ ) {
+ MainSwitch(title = getString(R.string.setting_news_toggle),
+ checked = mainSwitchChecked.value,
+ onCheckedChange = { t ->
+ // update checked value
+ mainSwitchChecked.value = t
+ // update shared preference
+ with (Global.sharedPreferences.edit()) {
+ putBoolean("newsPreview", t)
+ apply()
+ }
+ Global.gugalNews.enabled = t
+ // start news setup if there are no feeds
+ if ((Global.sharedPreferences.getStringSet(
+ "newsFeeds", null
+ )?.size ?: 0) == 0 /*&&*/|| t
+ ) {
+ startNewsSetup()
+ } else {
+ // restart main activity
+ startActivity(Intent(
+ this@SetupExtraNewsActivity,
+ MainActivity::class.java
+ ))
+ }
+ }
+ )
+ Text(
+ text = getText(R.string.news_body).toString(),
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(horizontal = 16.dp, vertical = 8.dp),
+ style = MaterialTheme.typography.bodyLarge
+ )
+ Tip(
+ text = getString(R.string.news_body_preview),
+ modifier = Modifier.padding(
+ top = 24.dp,
+ start = 16.dp,
+ end = 16.dp
+ ).fillMaxWidth(), icon = R.drawable.ic_info
+ )
+ }
+ }
+ }
+ )
+ }
+ }
+ }
+
+ private fun startNewsSetup() {
+ val intent = Intent(this, SetupNewsActivity::class.java)
+ intent.flags = Intent.FLAG_ACTIVITY_NEW_TASK
+ intent.putExtra("restartMain", true)
+ intent.putExtra("fromSUW", true)
+ this.startActivity(intent, null)
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/com/porg/gugal/setup/SetupFOSSActivity.kt b/app/src/main/java/com/porg/gugal/setup/SetupFOSSActivity.kt
index 52b27cefa04e15d429021a79434ec631a8e084b4..f170491ab2484d6bf1108af70bab13fc71601b84 100644
--- a/app/src/main/java/com/porg/gugal/setup/SetupFOSSActivity.kt
+++ b/app/src/main/java/com/porg/gugal/setup/SetupFOSSActivity.kt
@@ -1,7 +1,7 @@
/*
* SetupFOSSActivity.kt
* Gugal
- * Copyright (c) 2022 thegreatporg
+ * Copyright (c) 2022-2025 the Gugal maintainers
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
@@ -29,23 +29,35 @@ import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.annotation.RequiresApi
import androidx.compose.animation.ExperimentalAnimationApi
-import androidx.compose.foundation.layout.*
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.ExperimentalMaterialApi
import androidx.compose.material.icons.Icons
-import androidx.compose.material.icons.filled.ArrowBack
-import androidx.compose.material3.*
+import androidx.compose.material.icons.automirrored.filled.ArrowBack
+import androidx.compose.material3.ExperimentalMaterial3Api
+import androidx.compose.material3.Icon
+import androidx.compose.material3.IconButton
+import androidx.compose.material3.LargeTopAppBar
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Scaffold
+import androidx.compose.material3.Surface
+import androidx.compose.material3.Text
+import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.ui.Modifier
import androidx.compose.ui.input.nestedscroll.nestedScroll
+import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.core.view.WindowCompat
-import com.porg.gugal.MainActivity
+import com.porg.gugal.ui.MainActivity
import com.porg.gugal.R
import com.porg.gugal.ui.theme.GugalTheme
-import com.porg.m3.Tip
-import com.porg.m3.TwoButtons
+import tech.cataspect.m3x.Tip
+import tech.cataspect.m3x.TwoButtons
class SetupFOSSActivity : ComponentActivity() {
@OptIn(ExperimentalMaterialApi::class,
@@ -72,14 +84,28 @@ class SetupFOSSActivity : ComponentActivity() {
navigationIcon = {
IconButton(onClick = { finish() }) {
Icon(
- imageVector = Icons.Filled.ArrowBack,
- contentDescription = "Go back",
+ imageVector = Icons.AutoMirrored.Filled.ArrowBack,
+ contentDescription = stringResource(id = R.string.btn_back),
)
}
},
scrollBehavior = scrollBehavior
)
},
+ bottomBar = {
+ TwoButtons(
+ positiveAction = {
+ val intent = Intent(ctx, MainActivity::class.java)
+ intent.component =
+ ComponentName("com.porg.gugal", "com.porg.gugal.ui.MainActivity")
+ startActivity(intent)
+ },
+ positiveText = getString(R.string.btn_next),
+ negativeAction = {
+ finish()
+ }
+ )
+ },
content = { innerPadding ->
Surface(color = MaterialTheme.colorScheme.background) {
Column(modifier = Modifier
@@ -118,18 +144,6 @@ class SetupFOSSActivity : ComponentActivity() {
}
}
}
- TwoButtons(
- positiveAction = {
- val intent = Intent(ctx, MainActivity::class.java)
- intent.component =
- ComponentName("com.porg.gugal", "com.porg.gugal.MainActivity")
- startActivity(intent)
- },
- positiveText = getString(R.string.btn_next),
- negativeAction = {
- finish()
- }
- )
}
)
}
diff --git a/app/src/main/java/com/porg/gugal/setup/SetupNewStartActivity.kt b/app/src/main/java/com/porg/gugal/setup/SetupNewStartActivity.kt
index f2f4ae23624fd821ae1c231bab1fdff71c55cf23..55668117c2545246aa87c69108bbc92a00906981 100644
--- a/app/src/main/java/com/porg/gugal/setup/SetupNewStartActivity.kt
+++ b/app/src/main/java/com/porg/gugal/setup/SetupNewStartActivity.kt
@@ -1,7 +1,7 @@
/*
* SetupStartAcivity.kt
* Gugal
- * Copyright (c) 2022 thegreatporg
+ * Copyright (c) 2022-2025 the Gugal maintainers
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
@@ -28,6 +28,7 @@ import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material.Card
import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Scaffold
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.ui.Alignment
@@ -40,7 +41,7 @@ import androidx.compose.ui.unit.dp
import androidx.core.view.WindowCompat
import com.porg.gugal.R
import com.porg.gugal.ui.theme.GugalTheme
-import com.porg.m3.TwoButtons
+import tech.cataspect.m3x.TwoButtons
class SetupNewStartActivity : ComponentActivity() {
@@ -52,47 +53,52 @@ class SetupNewStartActivity : ComponentActivity() {
GugalTheme {
// A surface container using the 'background' color from the theme
Surface(color = MaterialTheme.colorScheme.background) {
- Column(
- modifier = Modifier.fillMaxSize(),
- verticalArrangement = Arrangement.Center
- ) {
- Card(
- modifier = Modifier
- .padding(all = 24.dp)
- .size(128.dp)
- .align(alignment = Alignment.CenterHorizontally),
- shape = CircleShape,
- backgroundColor = colorResource(R.color.icon_bg)
+ Scaffold(
+ bottomBar = {
+ TwoButtons(
+ positiveAction = { startActivity(Intent(ctx, SetupSelectSerpActivity::class.java)) },
+ positiveText = getString(R.string.setup_p1_button),
+ negativeAction = null
+ )
+ }
+ ) { padding ->
+ Column(
+ modifier = Modifier.fillMaxSize().padding(padding),
+ verticalArrangement = Arrangement.Center
) {
- Image(
- painterResource(R.drawable.ic_launcher_foreground),
- contentDescription = getString(R.string.desc_logo),
- contentScale = ContentScale.Crop,
- modifier = Modifier.fillMaxSize()
+ Card(
+ modifier = Modifier
+ .padding(all = 24.dp)
+ .size(128.dp)
+ .align(alignment = Alignment.CenterHorizontally),
+ shape = CircleShape,
+ backgroundColor = colorResource(R.color.icon_bg)
+ ) {
+ Image(
+ painterResource(R.drawable.ic_launcher_foreground),
+ contentDescription = getString(R.string.desc_logo),
+ contentScale = ContentScale.Crop,
+ modifier = Modifier.fillMaxSize()
+ )
+ }
+ Text(
+ text = getText(R.string.setup_p1_title).toString(),
+ modifier = Modifier
+ .padding(all = 16.dp)
+ .fillMaxWidth(),
+ textAlign = TextAlign.Center,
+ style = MaterialTheme.typography.headlineLarge
+ )
+ Text(
+ text = getText(R.string.setup_p1_description).toString(),
+ modifier = Modifier
+ .padding(all = 16.dp)
+ .fillMaxWidth(),
+ textAlign = TextAlign.Center,
+ style = MaterialTheme.typography.bodyLarge
)
}
- Text(
- text = getText(R.string.setup_p1_title).toString(),
- modifier = Modifier
- .padding(all = 16.dp)
- .fillMaxWidth(),
- textAlign = TextAlign.Center,
- style = MaterialTheme.typography.headlineLarge
- )
- Text(
- text = getText(R.string.setup_p1_description).toString(),
- modifier = Modifier
- .padding(all = 16.dp)
- .fillMaxWidth(),
- textAlign = TextAlign.Center,
- style = MaterialTheme.typography.bodyLarge
- )
}
- TwoButtons(
- positiveAction = { startActivity(Intent(ctx, SetupSelectSerpActivity::class.java)) },
- positiveText = getString(R.string.setup_p1_button),
- negativeAction = null
- )
}
}
}
diff --git a/app/src/main/java/com/porg/gugal/setup/SetupNewsActivity.kt b/app/src/main/java/com/porg/gugal/setup/SetupNewsActivity.kt
index 7ddab930a907ea207ec09da98e3b0d98992f2c25..0163f22bcc012116e327d7874c82bd4637c643a8 100644
--- a/app/src/main/java/com/porg/gugal/setup/SetupNewsActivity.kt
+++ b/app/src/main/java/com/porg/gugal/setup/SetupNewsActivity.kt
@@ -1,7 +1,7 @@
/*
* SetupNewsActivity.kt
* Gugal
- * Copyright (c) 2023 thegreatporg
+ * Copyright (c) 2023-2025 the Gugal maintainers
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
@@ -28,14 +28,13 @@ import androidx.compose.animation.ExperimentalAnimationApi
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
-import androidx.compose.foundation.layout.navigationBarsPadding
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material.ExperimentalMaterialApi
import androidx.compose.material.icons.Icons
-import androidx.compose.material.icons.filled.ArrowBack
+import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material3.Button
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
@@ -60,20 +59,23 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.input.nestedscroll.nestedScroll
import androidx.compose.ui.res.painterResource
+import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.core.view.WindowCompat
import androidx.lifecycle.viewmodel.compose.viewModel
import com.porg.gugal.Global
-import com.porg.gugal.MainActivity
+import com.porg.gugal.GugalApplication
+import com.porg.gugal.ui.MainActivity
import com.porg.gugal.R
import com.porg.gugal.news.NSViewModel
import com.porg.gugal.ui.theme.GugalTheme
-import com.porg.m3.TwoButtonsRaw
-import com.porg.m3.settings.CheckboxSetting
-import com.porg.m3.settings.RegularSetting
-import com.porg.m3.settings.SettingsTitle
import kotlinx.coroutines.launch
+import tech.cataspect.m3x.TwoButtons
+import tech.cataspect.m3x.settings.CheckboxSetting
+import tech.cataspect.m3x.settings.RegularSetting
+import tech.cataspect.m3x.settings.SettingsPadding
+import tech.cataspect.m3x.settings.SettingsTitle
class SetupNewsActivity: ComponentActivity() {
@OptIn(
@@ -120,8 +122,8 @@ class SetupNewsActivity: ComponentActivity() {
navigationIcon = {
IconButton(onClick = { finish() }) {
Icon(
- imageVector = Icons.Filled.ArrowBack,
- contentDescription = "Go back",
+ imageVector = Icons.AutoMirrored.Filled.ArrowBack,
+ contentDescription = stringResource(id = R.string.btn_back),
)
}
},
@@ -154,7 +156,7 @@ class SetupNewsActivity: ComponentActivity() {
item {
Text(
text = getString(R.string.setup_feeds_rec_desc),
- modifier = Modifier.padding(com.porg.m3.settings.SettingsPadding)
+ modifier = Modifier.padding(SettingsPadding)
)
SettingsTitle(getString(R.string.setup_feeds_subtitle_custom))
}
@@ -177,7 +179,7 @@ class SetupNewsActivity: ComponentActivity() {
}
},
bottomBar = {
- TwoButtonsRaw(
+ TwoButtons(
positiveAction = {
with(Global.sharedPreferences.edit()) {
this.putStringSet(
@@ -201,8 +203,7 @@ class SetupNewsActivity: ComponentActivity() {
negativeAction = {
finish()
},
- disablePositive = vmState.feeds.isEmpty(),
- modifier = Modifier.navigationBarsPadding()
+ disablePositive = vmState.feeds.isEmpty()
)
}
)
@@ -264,7 +265,7 @@ class SetupNewsActivity: ComponentActivity() {
}
}
if (vmState.feeds.isEmpty())
- vm.loadFeeds()
+ vm.loadFeeds((application as GugalApplication).container.demoMode)
}
}
}
\ No newline at end of file
diff --git a/app/src/main/java/com/porg/gugal/setup/SetupSelectSerpActivity.kt b/app/src/main/java/com/porg/gugal/setup/SetupSelectSerpActivity.kt
index 78f9d8270d326711be34baa8a39696f2541dffcd..f8f20d9d58a8babc5135919ac9e5ada7e6e7ab70 100644
--- a/app/src/main/java/com/porg/gugal/setup/SetupSelectSerpActivity.kt
+++ b/app/src/main/java/com/porg/gugal/setup/SetupSelectSerpActivity.kt
@@ -1,7 +1,7 @@
/*
* SetupSelectSerpActivity.kt
* Gugal
- * Copyright (c) 2022 thegreatporg
+ * Copyright (c) 2022-2025 the Gugal maintainers
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
@@ -19,7 +19,6 @@
package com.porg.gugal.setup
-import android.content.ComponentName
import android.content.Intent
import android.os.Bundle
import androidx.activity.ComponentActivity
@@ -31,7 +30,7 @@ import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
-import androidx.compose.material.icons.filled.ArrowBack
+import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
@@ -45,19 +44,22 @@ import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.ui.Modifier
import androidx.compose.ui.input.nestedscroll.nestedScroll
+import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextOverflow
import androidx.core.view.WindowCompat
import com.porg.gugal.BuildConfig
import com.porg.gugal.Global
import com.porg.gugal.Global.Companion.setSerpProvider
import com.porg.gugal.Global.Companion.sharedPreferences
+import com.porg.gugal.GugalApplication
import com.porg.gugal.R
import com.porg.gugal.providers.ProviderInfo
import com.porg.gugal.providers.cse.GoogleCseSerp
import com.porg.gugal.providers.searx.SearXSerp
+import com.porg.gugal.providers.stract.StractSerp
import com.porg.gugal.ui.theme.GugalTheme
-import com.porg.m3.TwoButtons
-import com.porg.m3.settings.RadioSetting
+import tech.cataspect.m3x.TwoButtons
+import tech.cataspect.m3x.settings.RadioSetting
class SetupSelectSerpActivity : ComponentActivity() {
@OptIn(
@@ -68,6 +70,12 @@ class SetupSelectSerpActivity : ComponentActivity() {
super.onCreate(savedInstanceState)
val currentSerpProviderId = mutableStateOf(sharedPreferences.getString("serp", "") ?: "")
val ctx = this
+ // Clear the current SERP provider ID if it is unknown
+ if (!Global.allSerpProviders.contains(currentSerpProviderId.value)) {
+ currentSerpProviderId.value = ""
+ }
+
+ val launchedFromBackupLoad = intent.getBooleanExtra("launchedFromBackupLoad", false)
WindowCompat.setDecorFitsSystemWindows(window, false)
@@ -92,25 +100,56 @@ class SetupSelectSerpActivity : ComponentActivity() {
navigationIcon = {
IconButton(onClick = { finish() }) {
Icon(
- imageVector = Icons.Filled.ArrowBack,
- contentDescription = "Go back",
+ imageVector = Icons.AutoMirrored.Filled.ArrowBack,
+ contentDescription = stringResource(id = R.string.btn_back),
)
}
},
scrollBehavior = scrollBehavior
)
},
+ bottomBar = {
+ TwoButtons(
+ positiveAction = {
+ setSerpProvider(_csp_id)
+ val info = getInfo(_csp_id)
+
+ saveSerp(_csp_id, info?.requiresSetup ?: false)
+
+ val intent = if (info?.requiresSetup!!) {
+ Intent(ctx, SetupConfigureSerpActivity::class.java)
+ } else {
+ if ((application as GugalApplication).container.experiments["gugalNewsSetup"]?.enabled == true) {
+ Intent(ctx, SetupExtraNewsActivity::class.java)
+ } else {
+ Intent(ctx, SetupFOSSActivity::class.java)
+ }
+ }
+ startActivity(intent)
+ },
+ positiveText = getString(R.string.btn_next),
+ disablePositive = _csp_id == "",
+ negativeAction = {
+ finish()
+ }
+ )
+ },
content = { innerPadding ->
Surface(color = MaterialTheme.colorScheme.background) {
Column(
- modifier = Modifier.padding(innerPadding)
- .verticalScroll(rememberScrollState()).fillMaxSize()
+ modifier = Modifier
+ .padding(innerPadding)
+ .verticalScroll(rememberScrollState())
+ .fillMaxSize()
) {
+ if (launchedFromBackupLoad) {
+ Text(stringResource(R.string.setup_p2_badrestore))
+ }
Global.allSerpProviders.forEach { serpID ->
val info: ProviderInfo = getInfo(serpID)!!
RadioSetting(
- title = info.name,
- body = info.description,
+ title = stringResource(info.name),
+ body = stringResource(info.description),
onClick = {
currentSerpProviderId.value = serpID
},
@@ -120,26 +159,6 @@ class SetupSelectSerpActivity : ComponentActivity() {
}
}
}
- TwoButtons(
- positiveAction = {
- setSerpProvider(_csp_id)
- val info = getInfo(_csp_id)
-
- var nextActivity = "SetupFOSSActivity"
- if (info?.requiresSetup!!) nextActivity = "SetupConfigureSerpActivity"
- saveSerp(_csp_id, info.requiresSetup)
-
- val intent = Intent(ctx, SetupConfigureSerpActivity::class.java)
- intent.component =
- ComponentName("com.porg.gugal", "com.porg.gugal.setup.$nextActivity")
- startActivity(intent)
- },
- positiveText = getString(R.string.btn_next),
- disablePositive = _csp_id == "",
- negativeAction = {
- finish()
- }
- )
}
)
}
@@ -150,6 +169,7 @@ class SetupSelectSerpActivity : ComponentActivity() {
private fun getInfo(serpID: String): ProviderInfo? {
if (serpID == GoogleCseSerp.id) return GoogleCseSerp.providerInfo
else if (serpID == SearXSerp.id) return SearXSerp.providerInfo
+ else if (serpID == StractSerp.id) return StractSerp.providerInfo
// Check if serpID matches your SERP provider's ID, and if so return your SERP provider's
// provider info.
return null
diff --git a/app/src/main/java/com/porg/gugal/setup/SetupStartActivity.kt b/app/src/main/java/com/porg/gugal/setup/SetupStartActivity.kt
deleted file mode 100644
index 8c68473161173781d82ee6602548dae43599196b..0000000000000000000000000000000000000000
--- a/app/src/main/java/com/porg/gugal/setup/SetupStartActivity.kt
+++ /dev/null
@@ -1,100 +0,0 @@
-/*
- * SetupStartAcivity.kt
- * Gugal
- * Copyright (c) 2022 thegreatporg
- *
- * This program is free software: you can redistribute it and/or modify
- * it under the terms of the GNU General Public License as published by
- * the Free Software Foundation, either version 3 of the License, or
- * (at your option) any later version.
- *
- * This program is distributed in the hope that it will be useful,
- * but WITHOUT ANY WARRANTY; without even the implied warranty of
- * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- * GNU General Public License for more details.
- *
- * You should have received a copy of the GNU General Public License
- * along with this program. If not, see .
- */
-
-package com.porg.gugal.setup
-
-import android.content.Intent
-import android.os.Bundle
-import androidx.activity.ComponentActivity
-import androidx.activity.compose.setContent
-import androidx.compose.foundation.Image
-import androidx.compose.foundation.layout.*
-import androidx.compose.foundation.shape.CircleShape
-import androidx.compose.material.Card
-import androidx.compose.material3.Button
-import androidx.compose.material3.MaterialTheme
-import androidx.compose.material3.Surface
-import androidx.compose.material3.Text
-import androidx.compose.ui.Alignment
-import androidx.compose.ui.Modifier
-import androidx.compose.ui.layout.ContentScale
-import androidx.compose.ui.res.colorResource
-import androidx.compose.ui.res.painterResource
-import androidx.compose.ui.text.style.TextAlign
-import androidx.compose.ui.unit.dp
-import androidx.core.view.WindowCompat
-import com.porg.gugal.R
-import com.porg.gugal.ui.theme.GugalTheme
-
-class SetupStartActivity : ComponentActivity() {
-
- override fun onCreate(savedInstanceState: Bundle?) {
- super.onCreate(savedInstanceState)
- val ctx = this
- WindowCompat.setDecorFitsSystemWindows(window, false)
- setContent {
- GugalTheme {
- // A surface container using the 'background' color from the theme
- Surface(color = MaterialTheme.colorScheme.background) {
- Column(
- modifier = Modifier.fillMaxSize(),
- verticalArrangement = Arrangement.Center
- ) {
- Card(
- modifier = Modifier.padding(all = 24.dp).size(128.dp)
- .align(alignment = Alignment.CenterHorizontally),
- shape = CircleShape,
- backgroundColor = colorResource(R.color.icon_bg)
- ) {
- Image(
- painterResource(R.drawable.ic_launcher_foreground),
- contentDescription = getString(R.string.desc_logo),
- contentScale = ContentScale.Crop,
- modifier = Modifier.fillMaxSize()
- )
- }
- Text(
- text = getText(R.string.setup_p1_title).toString(),
- modifier = Modifier.padding(all = 16.dp).fillMaxWidth(),
- textAlign = TextAlign.Center,
- style = MaterialTheme.typography.titleLarge
- )
- Text(
- text = getText(R.string.setup_p1_description).toString(),
- modifier = Modifier.padding(all = 32.dp).fillMaxWidth(),
- textAlign = TextAlign.Center,
- style = MaterialTheme.typography.bodyLarge
- )
- Button(
- modifier = Modifier
- .fillMaxWidth()
- .padding(all = 16.dp),
- onClick = {
- val intent = Intent(ctx, SetupSelectSerpActivity::class.java)
- startActivity(intent)
- }
- ) {
- Text(getText(R.string.setup_p1_button).toString())
- }
- }
- }
- }
- }
- }
-}
\ No newline at end of file
diff --git a/app/src/main/java/com/porg/gugal/setup/ui/SetupLayout.kt b/app/src/main/java/com/porg/gugal/setup/ui/SetupLayout.kt
index 9d2adb933b5524c9f3502c680a640772477f0bd3..36e684d5c45eed8bfe870579bb60c4ea7b64db57 100644
--- a/app/src/main/java/com/porg/gugal/setup/ui/SetupLayout.kt
+++ b/app/src/main/java/com/porg/gugal/setup/ui/SetupLayout.kt
@@ -1,7 +1,7 @@
/*
* SetupLayout.kt
* Gugal
- * Copyright (c) 2023 thegreatporg
+ * Copyright (c) 2023-2025 the Gugal maintainers
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
@@ -25,15 +25,24 @@ import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.material.icons.Icons
-import androidx.compose.material.icons.filled.ArrowBack
-import androidx.compose.material3.*
+import androidx.compose.material.icons.automirrored.filled.ArrowBack
+import androidx.compose.material3.ExperimentalMaterial3Api
+import androidx.compose.material3.Icon
+import androidx.compose.material3.IconButton
+import androidx.compose.material3.LargeTopAppBar
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Scaffold
+import androidx.compose.material3.Text
+import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.material3.windowsizeclass.WindowSizeClass
import androidx.compose.material3.windowsizeclass.WindowWidthSizeClass
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.input.nestedscroll.nestedScroll
+import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
+import com.porg.gugal.R
/**
* A large header with large text and optionally a Back button.
@@ -54,8 +63,8 @@ fun SetupPermaExpandedHeader(text: String, doFinish: (() -> Unit)?, showBackButt
then(Modifier.padding(start = 16.dp, top = 16.dp, bottom = 0.dp, end = 16.dp))
) {
Icon(
- imageVector = Icons.Filled.ArrowBack,
- contentDescription = "Go back",
+ imageVector = Icons.AutoMirrored.Filled.ArrowBack,
+ contentDescription = stringResource(id = R.string.btn_back),
)
}
Text(
@@ -91,8 +100,8 @@ fun SetupLayout(
navigationIcon = {
IconButton(onClick = onBackButtonClick) {
Icon(
- imageVector = Icons.Filled.ArrowBack,
- contentDescription = "Go back",
+ imageVector = Icons.AutoMirrored.Filled.ArrowBack,
+ contentDescription = stringResource(id = R.string.btn_back),
)
}
},
diff --git a/app/src/main/java/com/porg/gugal/ui/AboutActivity.kt b/app/src/main/java/com/porg/gugal/ui/AboutActivity.kt
index 259a4840b1d087e8e5ecb565156e7b9250e07533..a2b8df71b0777a09a3b988b9afec4aa1c4062b6c 100644
--- a/app/src/main/java/com/porg/gugal/ui/AboutActivity.kt
+++ b/app/src/main/java/com/porg/gugal/ui/AboutActivity.kt
@@ -1,7 +1,7 @@
/*
* AboutActivity.kt
* Gugal
- * Copyright (c) 2023 thegreatporg
+ * Copyright (c) 2023-2025 the Gugal maintainers
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
@@ -38,10 +38,11 @@ import androidx.compose.foundation.layout.width
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.verticalScroll
-import androidx.compose.material.Card
import androidx.compose.material.icons.Icons
-import androidx.compose.material.icons.filled.ArrowBack
-import androidx.compose.material3.Divider
+import androidx.compose.material.icons.automirrored.filled.ArrowBack
+import androidx.compose.material3.Card
+import androidx.compose.material3.CardDefaults
+import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
@@ -57,22 +58,22 @@ import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
-import androidx.core.content.ContextCompat
import androidx.core.view.WindowCompat
import com.porg.gugal.BuildConfig
-import com.porg.gugal.ChangelogActivity
+import com.porg.gugal.BuildInfo
import com.porg.gugal.DONATE_URL
import com.porg.gugal.ISSUE_URL
import com.porg.gugal.PREVIEW_ISSUE_URL
import com.porg.gugal.R
import com.porg.gugal.REPO_URL
+import com.porg.gugal.TEST_INFO_URL
import com.porg.gugal.TRANSLATE_URL
import com.porg.gugal.open
import com.porg.gugal.ui.theme.GugalTheme
-import com.porg.m3.Tip
-import com.porg.m3.settings.RegularSetting
-import com.porg.m3.settings.SettingsDividerPadding
-import com.porg.m3.settings.SettingsPadding
+import tech.cataspect.m3x.Tip
+import tech.cataspect.m3x.settings.RegularSetting
+import tech.cataspect.m3x.settings.SettingsDividerPadding
+import tech.cataspect.m3x.settings.SettingsPadding
import java.util.Locale
class AboutActivity : ComponentActivity() {
@@ -87,7 +88,9 @@ class AboutActivity : ComponentActivity() {
GugalTheme {
Surface(color = MaterialTheme.colorScheme.background) {
Column(
- modifier = Modifier.verticalScroll(rememberScrollState()).statusBarsPadding()
+ modifier = Modifier
+ .verticalScroll(rememberScrollState())
+ .statusBarsPadding()
) {
IconButton(
onClick = { finish() },
@@ -99,8 +102,8 @@ class AboutActivity : ComponentActivity() {
)
) {
Icon(
- imageVector = Icons.Filled.ArrowBack,
- contentDescription = "Go back",
+ imageVector = Icons.AutoMirrored.Filled.ArrowBack,
+ contentDescription = stringResource(id = R.string.btn_back),
)
}
Column(modifier = Modifier.fillMaxSize()) {
@@ -111,7 +114,9 @@ class AboutActivity : ComponentActivity() {
.size(128.dp)
.align(alignment = Alignment.CenterHorizontally),
shape = CircleShape,
- backgroundColor = colorResource(R.color.icon_bg)
+ colors = CardDefaults.cardColors().copy(
+ containerColor = colorResource(R.color.icon_bg)
+ )
) {
Image(
painterResource(R.drawable.ic_launcher_foreground),
@@ -127,7 +132,18 @@ class AboutActivity : ComponentActivity() {
style = MaterialTheme.typography.titleLarge
)
}
- if (BuildConfig.VERSION_NAME.contains(".p")) {
+ if (BuildInfo.isTest()) {
+ Tip(
+ text = getString(R.string.tip_testing),
+ modifier = Modifier.padding(
+ top = 24.dp,
+ start = 16.dp,
+ end = 16.dp
+ ), icon = R.drawable.ic_info,
+ onClick = { open(context, TEST_INFO_URL) }
+ )
+ }
+ else if (BuildInfo.isPreview()) {
Tip(
text = getString(R.string.tip_preview),
modifier = Modifier.padding(
@@ -138,34 +154,36 @@ class AboutActivity : ComponentActivity() {
onClick = { open(context, PREVIEW_ISSUE_URL) }
)
}
- RegularSetting(R.string.setting_donate_title,
- R.string.setting_donate_desc,
+ RegularSetting(
+ stringResource(R.string.setting_donate_title),
+ stringResource(R.string.setting_donate_desc),
onClick = { open(context, DONATE_URL) }
)
RegularSetting(
- R.string.setting_issue_title,
- R.string.setting_issue_desc,
+ stringResource(R.string.setting_issue_title),
+ stringResource(R.string.setting_issue_desc),
onClick = { open(context, ISSUE_URL) }
)
RegularSetting(
- R.string.setting_gitlab_title,
- R.string.setting_gitlab_desc,
+ stringResource(R.string.setting_gitlab_title),
+ stringResource(R.string.setting_gitlab_desc),
onClick = { open(context, REPO_URL) }
)
+ if (!BuildInfo.isTest())
RegularSetting(
- R.string.setting_changelog_title,
- R.string.setting_changelog_desc,
+ stringResource(R.string.setting_changelog_title),
+ stringResource(R.string.setting_changelog_desc),
onClick = {
val intent = Intent(context, ChangelogActivity::class.java)
- ContextCompat.startActivity(context, intent, null)
+ context.startActivity(intent, null)
}
)
RegularSetting(
- R.string.setting_translate_title,
- R.string.setting_translate_desc,
+ stringResource(R.string.setting_translate_title),
+ stringResource(R.string.setting_translate_desc),
onClick = { open(context, TRANSLATE_URL) }
)
- Divider(
+ HorizontalDivider(
modifier = Modifier
.fillMaxWidth()
.width(1.dp)
@@ -373,6 +391,10 @@ class AboutActivity : ComponentActivity() {
}
private fun getVersion(): String {
+ if (BuildInfo.isTest()) {
+ val blocks = BuildConfig.VERSION_NAME.split("-")
+ return "${blocks[2]}/${blocks[3]} (${blocks[1]} build: based on ${blocks[0]})"
+ }
val split = BuildConfig.VERSION_NAME.split(".p")
if (split.size == 2) {
return "${split[0]} preview ${split[1]}"
diff --git a/app/src/main/java/com/porg/gugal/ui/BackupCreateActivity.kt b/app/src/main/java/com/porg/gugal/ui/BackupCreateActivity.kt
new file mode 100644
index 0000000000000000000000000000000000000000..67bb79632bd5a7547b745bcc4eabdd8884903f88
--- /dev/null
+++ b/app/src/main/java/com/porg/gugal/ui/BackupCreateActivity.kt
@@ -0,0 +1,157 @@
+/*
+ * BackupCreateActivity.kt
+ * Gugal
+ * Copyright (c) 2024-2025 the Gugal maintainers
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ */
+
+package com.porg.gugal.ui
+
+import android.content.Intent
+import android.net.Uri
+import android.os.Bundle
+import androidx.activity.ComponentActivity
+import androidx.activity.compose.rememberLauncherForActivityResult
+import androidx.activity.compose.setContent
+import androidx.activity.result.ActivityResult
+import androidx.activity.result.contract.ActivityResultContracts
+import androidx.compose.animation.AnimatedVisibility
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.PaddingValues
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.padding
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.automirrored.filled.ArrowBack
+import androidx.compose.material3.Button
+import androidx.compose.material3.ExperimentalMaterial3Api
+import androidx.compose.material3.Icon
+import androidx.compose.material3.IconButton
+import androidx.compose.material3.LargeTopAppBar
+import androidx.compose.material3.Scaffold
+import androidx.compose.material3.Text
+import androidx.compose.material3.TopAppBarDefaults
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.collectAsState
+import androidx.compose.runtime.getValue
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.input.nestedscroll.nestedScroll
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.text.style.TextOverflow
+import androidx.lifecycle.viewmodel.compose.viewModel
+import com.porg.gugal.R
+import com.porg.gugal.ui.theme.GugalTheme
+import com.porg.gugal.viewmodel.BackupCreateViewModel
+import tech.cataspect.m3x.Tip
+import tech.cataspect.m3x.settings.ToggleSetting
+import java.io.FileOutputStream
+
+class BackupCreateActivity: ComponentActivity() {
+ @OptIn(ExperimentalMaterial3Api::class)
+ override fun onCreate(savedInstanceState: Bundle?) {
+ super.onCreate(savedInstanceState)
+ setContent {
+ val scrollBehavior = TopAppBarDefaults.exitUntilCollapsedScrollBehavior()
+ GugalTheme {
+ Scaffold(
+ modifier = Modifier.nestedScroll(scrollBehavior.nestedScrollConnection),
+ topBar = {
+ LargeTopAppBar(
+ title = {
+ Text(
+ getText(R.string.backup_create_title).toString(),
+ maxLines = 1,
+ overflow = TextOverflow.Ellipsis
+ )
+ },
+ navigationIcon = {
+ IconButton(onClick = { finish() }) {
+ Icon(
+ imageVector = Icons.AutoMirrored.Filled.ArrowBack,
+ contentDescription = stringResource(id = R.string.btn_back),
+ )
+ }
+ },
+ scrollBehavior = scrollBehavior
+ )
+ },
+ content = { innerPadding ->
+ BackupCreateUI(innerPadding)
+ }
+ )
+ }
+ }
+ }
+
+ @Composable
+ private fun BackupCreateUI(
+ innerPadding: PaddingValues,
+ bkModel: BackupCreateViewModel = viewModel()
+ ) {
+ // register an activity result receiver
+ val startForResult = rememberLauncherForActivityResult(
+ ActivityResultContracts.StartActivityForResult()
+ ) { result: ActivityResult ->
+ if (result.resultCode == RESULT_OK) {
+ if (result.data == null) {
+ throw NullPointerException("Result is null")
+ }
+ // open the file
+ val descriptor = contentResolver.openFileDescriptor(result.data!!.data as Uri, "w")!!.fileDescriptor
+ // start the backup
+ with (FileOutputStream(descriptor)) {
+ bkModel.startBackup(this)
+ }
+ }
+ }
+ val bkState by bkModel.uiState.collectAsState()
+ Column(Modifier.padding(innerPadding)) {
+ Tip("This UI is temporary.", Modifier.fillMaxWidth(), R.drawable.ic_warning)
+ ToggleSetting(
+ stringResource(R.string.backup_create_enc_title),
+ stringResource(R.string.backup_create_enc_desc),
+ { t ->
+ bkModel.setEncrypt(t)
+ },
+ bkState.encrypt
+ )
+ AnimatedVisibility(bkState.encrypt) {
+ PasswordField(
+ "Password (W: not string)",
+ bkState.password ?: "",
+ { value -> bkModel.setPassword(value) },
+ Modifier.fillMaxWidth()
+ )
+ }
+ Button(
+ onClick = {
+ // create an intent to open a file picker that creates a json file
+ val fpIntent = Intent(Intent.ACTION_CREATE_DOCUMENT).apply {
+ addCategory(Intent.CATEGORY_OPENABLE)
+ type = "application/json"
+ putExtra(Intent.EXTRA_TITLE, "gugal_backup.json")
+ }
+ // start the file picker
+ startForResult.launch(fpIntent)
+ },
+ modifier = Modifier.fillMaxWidth()
+ ) {
+ Text("Start backup")
+ }
+ AnimatedVisibility(bkState.showSuccess) {
+ Text("Success")
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/com/porg/gugal/ui/BackupLoadActivity.kt b/app/src/main/java/com/porg/gugal/ui/BackupLoadActivity.kt
new file mode 100644
index 0000000000000000000000000000000000000000..b815ff481f985ae304908313ccdce92ec0c19d8f
--- /dev/null
+++ b/app/src/main/java/com/porg/gugal/ui/BackupLoadActivity.kt
@@ -0,0 +1,162 @@
+/*
+ * BackupLoadActivity.kt
+ * Gugal
+ * Copyright © 2025 the Gugal maintainers
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ */
+
+package com.porg.gugal.ui
+
+import android.content.Intent
+import android.net.Uri
+import android.os.Bundle
+import androidx.activity.ComponentActivity
+import androidx.activity.compose.rememberLauncherForActivityResult
+import androidx.activity.compose.setContent
+import androidx.activity.result.ActivityResult
+import androidx.activity.result.contract.ActivityResultContracts
+import androidx.compose.animation.AnimatedVisibility
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.PaddingValues
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.padding
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.automirrored.filled.ArrowBack
+import androidx.compose.material3.Button
+import androidx.compose.material3.ExperimentalMaterial3Api
+import androidx.compose.material3.Icon
+import androidx.compose.material3.IconButton
+import androidx.compose.material3.LargeTopAppBar
+import androidx.compose.material3.Scaffold
+import androidx.compose.material3.Text
+import androidx.compose.material3.TopAppBarDefaults
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.collectAsState
+import androidx.compose.runtime.getValue
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.input.nestedscroll.nestedScroll
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.text.style.TextOverflow
+import androidx.lifecycle.viewmodel.compose.viewModel
+import com.porg.gugal.R
+import com.porg.gugal.setup.SetupSelectSerpActivity
+import com.porg.gugal.ui.theme.GugalTheme
+import com.porg.gugal.viewmodel.BackupLoadError
+import com.porg.gugal.viewmodel.BackupLoadViewModel
+import tech.cataspect.m3x.Tip
+import java.io.FileInputStream
+
+class BackupLoadActivity: ComponentActivity() {
+ @OptIn(ExperimentalMaterial3Api::class)
+ override fun onCreate(savedInstanceState: Bundle?) {
+ super.onCreate(savedInstanceState)
+ setContent {
+ val scrollBehavior = TopAppBarDefaults.exitUntilCollapsedScrollBehavior()
+ GugalTheme {
+ Scaffold(
+ modifier = Modifier.nestedScroll(scrollBehavior.nestedScrollConnection),
+ topBar = {
+ LargeTopAppBar(
+ title = {
+ Text(
+ getText(R.string.backup_restore_title).toString(),
+ maxLines = 1,
+ overflow = TextOverflow.Ellipsis
+ )
+ },
+ navigationIcon = {
+ IconButton(onClick = { finish() }) {
+ Icon(
+ imageVector = Icons.AutoMirrored.Filled.ArrowBack,
+ contentDescription = stringResource(id = R.string.btn_back),
+ )
+ }
+ },
+ scrollBehavior = scrollBehavior
+ )
+ },
+ content = { innerPadding ->
+ BackupLoadUI(innerPadding)
+ }
+ )
+ }
+ }
+ }
+
+ @Composable
+ private fun BackupLoadUI(
+ innerPadding: PaddingValues,
+ bkModel: BackupLoadViewModel = viewModel()
+ ) {
+ val bkState by bkModel.uiState.collectAsState()
+
+ // respond to post-validation errors
+ LaunchedEffect(bkState.error) {
+ if (bkState.error == BackupLoadError.UNKNOWN_SERP_PROVIDER) {
+ val it = Intent(this@BackupLoadActivity, SetupSelectSerpActivity::class.java)
+ it.putExtra("launchedFromBackupLoad", true)
+ startActivity(it)
+ }
+ }
+
+ // register an activity result receiver
+ val startForResult = rememberLauncherForActivityResult(
+ ActivityResultContracts.StartActivityForResult()
+ ) { result: ActivityResult ->
+ if (result.resultCode == RESULT_OK) {
+ if (result.data == null) {
+ throw NullPointerException("Result is null")
+ }
+ // open the file
+ val descriptor = contentResolver.openFileDescriptor(result.data!!.data as Uri, "r")!!.fileDescriptor
+ // start the restore
+ with (FileInputStream(descriptor)) {
+ bkModel.loadBackup(this)
+ }
+ }
+ }
+ LaunchedEffect(null) {
+ // create an intent to open a file picker that creates a json file
+ val fpIntent = Intent(Intent.ACTION_OPEN_DOCUMENT).apply {
+ addCategory(Intent.CATEGORY_OPENABLE)
+ type = "application/json"
+ }
+ // start the file picker
+ startForResult.launch(fpIntent)
+ }
+ Column(Modifier.padding(innerPadding)) {
+ Tip("This UI is temporary.", Modifier.fillMaxWidth(), R.drawable.ic_warning)
+ AnimatedVisibility(bkState.showPasswordDialog) {
+ PasswordField(
+ "Password (W: not string)",
+ bkState.password ?: "",
+ { value -> bkModel.setPassword(value) },
+ Modifier.fillMaxWidth()
+ )
+ Button(
+ onClick = {
+ bkModel.loadSavedBackup()
+ }
+ ) {
+ Text("Restore (W: not string)")
+ }
+ }
+ AnimatedVisibility(bkState.showSuccess) {
+ Text("Success")
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/com/porg/gugal/ui/BackupSettingsActivity.kt b/app/src/main/java/com/porg/gugal/ui/BackupSettingsActivity.kt
new file mode 100644
index 0000000000000000000000000000000000000000..e5197df0b077e6958d7e43d80a0f74b097ec8744
--- /dev/null
+++ b/app/src/main/java/com/porg/gugal/ui/BackupSettingsActivity.kt
@@ -0,0 +1,209 @@
+/*
+ * BackupSettingsActivity.kt
+ * Gugal
+ * Copyright (c) 2024-2025 the Gugal maintainers
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ */
+
+package com.porg.gugal.ui
+
+import android.content.Intent
+import android.net.Uri
+import android.os.Bundle
+import android.widget.Toast
+import androidx.activity.ComponentActivity
+import androidx.activity.compose.rememberLauncherForActivityResult
+import androidx.activity.compose.setContent
+import androidx.activity.result.ActivityResult
+import androidx.activity.result.contract.ActivityResultContracts
+import androidx.compose.animation.AnimatedVisibility
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.rememberScrollState
+import androidx.compose.foundation.verticalScroll
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.automirrored.filled.ArrowBack
+import androidx.compose.material3.Button
+import androidx.compose.material3.ExperimentalMaterial3Api
+import androidx.compose.material3.Icon
+import androidx.compose.material3.IconButton
+import androidx.compose.material3.LargeTopAppBar
+import androidx.compose.material3.ModalBottomSheet
+import androidx.compose.material3.Scaffold
+import androidx.compose.material3.Text
+import androidx.compose.material3.TopAppBarDefaults
+import androidx.compose.material3.rememberModalBottomSheetState
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.collectAsState
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.input.nestedscroll.nestedScroll
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.text.style.TextOverflow
+import androidx.lifecycle.viewmodel.compose.viewModel
+import com.porg.gugal.R
+import com.porg.gugal.ui.theme.GugalTheme
+import com.porg.gugal.viewmodel.BackupCreateViewModel
+import com.porg.m3.settings.SettingsDividerPadding
+import kotlinx.coroutines.launch
+import tech.cataspect.m3x.settings.RegularSetting
+import tech.cataspect.m3x.settings.SettingsPadding
+import tech.cataspect.m3x.settings.ToggleSetting
+import java.io.FileOutputStream
+
+class BackupSettingsActivity: ComponentActivity() {
+
+ @OptIn(ExperimentalMaterial3Api::class)
+ override fun onCreate(savedInstanceState: Bundle?) {
+ super.onCreate(savedInstanceState)
+ setContent {
+ val openBkCreate = remember { mutableStateOf(false) }
+ val bkc: BackupCreateViewModel = viewModel()
+ val scrollBehavior = TopAppBarDefaults.exitUntilCollapsedScrollBehavior()
+ GugalTheme {
+ Scaffold(
+ modifier = Modifier.nestedScroll(scrollBehavior.nestedScrollConnection),
+ topBar = {
+ LargeTopAppBar(
+ title = {
+ Text(
+ getText(R.string.setting_backup_title).toString(),
+ maxLines = 1,
+ overflow = TextOverflow.Ellipsis
+ )
+ },
+ navigationIcon = {
+ IconButton(onClick = { finish() }) {
+ Icon(
+ imageVector = Icons.AutoMirrored.Filled.ArrowBack,
+ contentDescription = stringResource(id = R.string.btn_back),
+ )
+ }
+ },
+ scrollBehavior = scrollBehavior
+ )
+ },
+ content = { innerPadding ->
+ Column(modifier = Modifier
+ .padding(innerPadding)
+ .verticalScroll(rememberScrollState())
+ .fillMaxSize()
+ ) {
+ RegularSetting(
+ title = stringResource(R.string.backup_create_title),
+ body = stringResource(R.string.backup_create_desc)
+ ) {
+ openBkCreate.value = true
+ }
+ RegularSetting(
+ title = stringResource(R.string.backup_restore_title),
+ body = stringResource(R.string.backup_restore_desc)
+ ) {
+ startActivity(Intent(
+ this@BackupSettingsActivity,
+ BackupLoadActivity::class.java
+ ))
+ }
+ }
+ if (openBkCreate.value)
+ BackupCreateUI({
+ openBkCreate.value = false
+ bkc.successShown()
+ }, bkc)
+ }
+ )
+ }
+ }
+ }
+
+ @OptIn(ExperimentalMaterial3Api::class)
+ @Composable
+ private fun BackupCreateUI(
+ onDismissRequest: () -> Unit,
+ bkModel: BackupCreateViewModel
+ ) {
+ val sheetState = rememberModalBottomSheetState()
+ // register an activity result receiver
+ val startForResult = rememberLauncherForActivityResult(
+ ActivityResultContracts.StartActivityForResult()
+ ) { result: ActivityResult ->
+ if (result.resultCode == RESULT_OK) {
+ if (result.data == null) {
+ throw NullPointerException("Result is null")
+ }
+ // open the file
+ val descriptor = contentResolver.openFileDescriptor(result.data!!.data as Uri, "w")!!.fileDescriptor
+ // start the backup
+ with (FileOutputStream(descriptor)) {
+ bkModel.startBackup(this)
+ }
+ }
+ }
+ val bkState by bkModel.uiState.collectAsState()
+ ModalBottomSheet(onDismissRequest = onDismissRequest, sheetState = sheetState) {
+ ToggleSetting(
+ stringResource(R.string.backup_create_enc_title),
+ stringResource(R.string.backup_create_enc_desc),
+ { t ->
+ bkModel.setEncrypt(t)
+ },
+ bkState.encrypt
+ )
+ AnimatedVisibility(bkState.encrypt) {
+ PasswordField(
+ label = "Password (W: not string)",
+ value = bkState.password ?: "",
+ onValueChanged = { value -> bkModel.setPassword(value) },
+ modifier = Modifier.fillMaxWidth().padding(SettingsPadding, SettingsDividerPadding),
+ isError = bkState.error != null
+ )
+ }
+ Button(
+ onClick = {
+ if (bkModel.checkPassword() == null) {
+ // create an intent to open a file picker that creates a json file
+ val fpIntent = Intent(Intent.ACTION_CREATE_DOCUMENT).apply {
+ addCategory(Intent.CATEGORY_OPENABLE)
+ type = "application/json"
+ putExtra(Intent.EXTRA_TITLE, "gugal_backup.json")
+ }
+ // start the file picker
+ startForResult.launch(fpIntent)
+ }
+ },
+ modifier = Modifier.fillMaxWidth().padding(SettingsPadding)
+ ) {
+ Text(getString(R.string.backup_create_btn_title))
+ }
+ LaunchedEffect(bkState.showSuccess) {
+ if (bkState.showSuccess) {
+ Toast.makeText(this@BackupSettingsActivity, "Success", Toast.LENGTH_SHORT)
+ .show()
+ launch { sheetState.hide() }.invokeOnCompletion {
+ if (!sheetState.isVisible) {
+ onDismissRequest()
+ }
+ }
+ }
+ }
+ }
+ }
+}
+
diff --git a/app/src/main/java/com/porg/gugal/ChangelogActivity.kt b/app/src/main/java/com/porg/gugal/ui/ChangelogActivity.kt
similarity index 74%
rename from app/src/main/java/com/porg/gugal/ChangelogActivity.kt
rename to app/src/main/java/com/porg/gugal/ui/ChangelogActivity.kt
index 6a4a68ec635ae50949e579f11b21a2e843451e64..0c63a893d8081d982319411898311412d47d19e8 100644
--- a/app/src/main/java/com/porg/gugal/ChangelogActivity.kt
+++ b/app/src/main/java/com/porg/gugal/ui/ChangelogActivity.kt
@@ -1,7 +1,7 @@
/*
* ChangelogActivity.kt
* Gugal
- * Copyright (c) 2022 thegreatporg
+ * Copyright (c) 2024-2025 the Gugal maintainers
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
@@ -17,7 +17,7 @@
* along with this program. If not, see .
*/
-package com.porg.gugal
+package com.porg.gugal.ui
import android.os.Bundle
import androidx.activity.ComponentActivity
@@ -29,19 +29,33 @@ import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
-import androidx.compose.material.icons.filled.ArrowBack
-import androidx.compose.material3.*
+import androidx.compose.material.icons.automirrored.filled.ArrowBack
+import androidx.compose.material3.ExperimentalMaterial3Api
+import androidx.compose.material3.Icon
+import androidx.compose.material3.IconButton
+import androidx.compose.material3.LargeTopAppBar
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Scaffold
+import androidx.compose.material3.Surface
+import androidx.compose.material3.Text
+import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.ui.Modifier
import androidx.compose.ui.input.nestedscroll.nestedScroll
+import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import com.android.volley.Request
import com.android.volley.toolbox.StringRequest
import com.android.volley.toolbox.Volley
+import com.porg.gugal.BuildConfig
+import com.porg.gugal.BuildInfo
+import com.porg.gugal.CHANGELOG_URL
+import com.porg.gugal.PREVIEW_CHANGELOG_URL
+import com.porg.gugal.R
import com.porg.gugal.ui.theme.GugalTheme
-import com.porg.m3.TwoButtons
+import tech.cataspect.m3x.TwoButtons
class ChangelogActivity : ComponentActivity() {
@OptIn(ExperimentalMaterial3Api::class)
@@ -70,40 +84,49 @@ class ChangelogActivity : ComponentActivity() {
navigationIcon = {
IconButton(onClick = { finish() }) {
Icon(
- imageVector = Icons.Filled.ArrowBack,
- contentDescription = "Go back",
+ imageVector = Icons.AutoMirrored.Filled.ArrowBack,
+ contentDescription = stringResource(id = R.string.btn_back),
)
}
},
scrollBehavior = scrollBehavior
)
},
+ bottomBar = {
+ TwoButtons(
+ positiveAction = { finish() },
+ positiveText = getString(R.string.btn_close),
+ negativeAction = null
+ )
+ },
content = { innerPadding ->
Surface(color = MaterialTheme.colorScheme.background) {
Column(
- modifier = Modifier.padding(innerPadding)
- .verticalScroll(rememberScrollState()).fillMaxSize()
+ modifier = Modifier
+ .padding(innerPadding)
+ .verticalScroll(rememberScrollState())
+ .fillMaxSize()
) {
val _change by changelog
Text(
text = _change,
modifier = Modifier
- .padding(start = 24.dp, top = 0.dp, bottom = 24.dp, end = 24.dp)
+ .padding(
+ start = 24.dp,
+ top = 0.dp,
+ bottom = 24.dp,
+ end = 24.dp
+ )
.fillMaxWidth(),
style = MaterialTheme.typography.bodyLarge
)
}
}
- TwoButtons(
- positiveAction = { finish() },
- positiveText = getString(R.string.btn_close),
- negativeAction = null
- )
}
)
}
}
- if (BuildConfig.VERSION_NAME.contains("pr-") || BuildConfig.VERSION_NAME.contains("ci-")) {
+ if (BuildInfo.isTest()) {
changelog.value = getString(R.string.changelog_ci)
} else {
// Instantiate the RequestQueue.
diff --git a/app/src/main/java/com/porg/gugal/ui/Components.kt b/app/src/main/java/com/porg/gugal/ui/Components.kt
index 86b4b82712150d9653b6010fa4b0820024d0f8ab..6875c6074f374c801befd799b9620d6d25ae735f 100644
--- a/app/src/main/java/com/porg/gugal/ui/Components.kt
+++ b/app/src/main/java/com/porg/gugal/ui/Components.kt
@@ -1,7 +1,7 @@
/*
* Components.kt
* Gugal
- * Copyright (c) 2022 thegreatporg
+ * Copyright (c) 2022-2025 the Gugal maintainers
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
@@ -48,25 +48,25 @@ import androidx.compose.ui.graphics.ColorFilter
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
-import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import androidx.core.content.ContextCompat
-import com.porg.gugal.Global
import com.porg.gugal.R
-import com.porg.gugal.data.Result
-import com.porg.gugal.ui.theme.GugalTheme
+import com.porg.gugal.data.results.Result
import com.porg.gugal.ui.theme.shapeScheme
import com.porg.gugal.viewmodel.ResultViewModel
class Components {
companion object {
+
/**
* A medium Gugal logo.
*
* @param modifier a modifier to be applied, optional.
*/
@Composable
+ @Deprecated("Moved to a more Compose-style declaration in the `com.porg.gugal.ui.components` package.",
+ ReplaceWith("com.porg.gugal.ui.components.GugalLogo(modifier, size, padding)", "com.porg.gugal.ui.components.GugalLogo"))
fun GugalLogo(modifier: Modifier = Modifier, size: Dp = 96.dp, padding: Dp = 48.dp) {
val bg = MaterialTheme.colorScheme.primary
val fg = MaterialTheme.colorScheme.onPrimary
@@ -95,7 +95,13 @@ class Components {
* @param context the context of the calling activity, optional (only required for clickable results).
*/
@Composable
- fun ResultCardV2(res: Result, context: Context?, modifier: Modifier = Modifier) {
+ @Deprecated("Moved to a more Compose-style declaration in the `com.porg.gugal.ui.components` package.",
+ ReplaceWith(
+ "ResultCard(res, context, modifier, customTab)",
+ "com.porg.gugal.ui.components.ResultCard"
+ )
+ )
+ fun ResultCardV2(res: Result, context: Context?, modifier: Modifier = Modifier, customTab: Boolean = false) {
Surface(
shape = MaterialTheme.shapeScheme.medium,
tonalElevation = cardElevation,
@@ -103,7 +109,7 @@ class Components {
.padding(all = 4.dp)
.fillMaxWidth()
.clickable(onClick = {
- if (res.url != "") openBrowser(context!!, res.url)
+ if (res.url != "") openBrowser(context!!, res.url, customTab)
})
.then(modifier),
) {
@@ -131,53 +137,48 @@ class Components {
}
}
- @Composable
- @Preview
- fun PreviewResultCardV2() {
- GugalTheme {
- ResultCardV2(
- res = Result(
- "Gugal",
- "A clean, lightweight, FOSS web search app.",
- "https://gitlab.com/narektor/gugal",
- "gitlab.com"),
- context = null
- )
- }
- }
-
/**
* A list of ResultCards.
*
* @param results the `Result`s to show.
* @param context the context of the calling activity, optional (only required for clickable results).
* @param sizeClass the current device's size class.
+ * @param usePages should pages be used?
*/
@ExperimentalAnimationApi
@Composable
+ @Deprecated("Moved to a more Compose-style declaration in the `com.porg.gugal.ui.components` package.",
+ ReplaceWith(
+ "com.porg.gugal.ui.components.Results(results, context, sizeClass, resultModel, usePages, useGrid, useCustomTabs)",
+ "com.porg.gugal.ui.components.Results"
+ )
+ )
fun Results(
results: List,
context: Context?,
sizeClass: WindowWidthSizeClass,
- resultModel: ResultViewModel? = null
+ resultModel: ResultViewModel? = null,
+ usePages: Boolean = false,
+ useGrid: Boolean = false,
+ useCustomTabs: Boolean = false
) {
if (sizeClass == WindowWidthSizeClass.Compact)
LazyColumn {
items(results) { message ->
- ResultCardV2(message, context)
+ ResultCardV2(message, context, customTab = useCustomTabs)
}
- if (Global.searchPages.enabled)
+ if (usePages)
item { PageButtons(resultModel!!) }
}
else {
- if (Global.resultGrid.enabled)
+ if (useGrid)
LazyVerticalGrid(
columns = GridCells.Adaptive(minSize = 384.dp)
) {
items(results) { message ->
- ResultCardV2(message, context, Modifier.heightIn(min = 40.dp))
+ ResultCardV2(message, context, Modifier.heightIn(min = 40.dp), customTab = useCustomTabs)
}
- if (Global.searchPages.enabled)
+ if (usePages)
item { PageButtons(resultModel!!) }
}
else
@@ -185,36 +186,16 @@ class Components {
Modifier.padding(horizontal = 96.dp)
) {
items(results) { message ->
- ResultCardV2(message, context)
+ ResultCardV2(message, context, customTab = useCustomTabs)
}
- if (Global.searchPages.enabled)
+ if (usePages)
item { PageButtons(resultModel!!) }
}
}
}
- @ExperimentalAnimationApi
- @Composable
- @Preview
- fun ResultsPreview() {
- GugalTheme {
- Results(
- results = List(size = 3) {
- Result(
- "Google privacy scandal",
- "Google have been fined for antitrust once again, a few days after people bought more than 5 Pixels.",
- "about:blank",
- "test.com"
- )
- },
- null,
- WindowWidthSizeClass.Compact
- )
- }
- }
-
- private fun openBrowser(context: Context, url: String) {
- if (Global.custTabInResults.enabled) {
+ private fun openBrowser(context: Context, url: String, customTab: Boolean = false) {
+ if (customTab) {
val builder = CustomTabsIntent.Builder()
// show website title
builder.setShowTitle(true)
diff --git a/app/src/main/java/com/porg/gugal/ui/CredentialField.kt b/app/src/main/java/com/porg/gugal/ui/CredentialField.kt
new file mode 100644
index 0000000000000000000000000000000000000000..c2c219fde1850d764f5651643990d8a1f70055f1
--- /dev/null
+++ b/app/src/main/java/com/porg/gugal/ui/CredentialField.kt
@@ -0,0 +1,73 @@
+/*
+ * CredentialField.kt
+ * Gugal
+ * Copyright © 2025 the Gugal maintainers
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ */
+
+package com.porg.gugal.ui
+
+import androidx.compose.material3.Icon
+import androidx.compose.material3.IconButton
+import androidx.compose.material3.OutlinedTextField
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.saveable.rememberSaveable
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.graphics.vector.ImageVector
+import androidx.compose.ui.res.vectorResource
+import androidx.compose.ui.text.input.PasswordVisualTransformation
+import androidx.compose.ui.text.input.VisualTransformation
+import com.porg.gugal.R
+
+@Composable
+fun PasswordField(
+ label: String,
+ value: String,
+ onValueChanged: (String) -> Unit,
+ modifier: Modifier = Modifier,
+ isError: Boolean = false
+) {
+ var passwordVisible by rememberSaveable { mutableStateOf(false) }
+ OutlinedTextField(
+ value = value,
+ onValueChange = onValueChanged,
+ label = { Text(label) },
+ singleLine = true,
+ visualTransformation =
+ if (passwordVisible) VisualTransformation.None else PasswordVisualTransformation(),
+ isError = isError,
+ trailingIcon = {
+ val image = ImageVector.vectorResource(
+ if (passwordVisible) R.drawable.ic_visibility_off
+ else R.drawable.ic_visibility_on
+ )
+
+ // Please provide localized description for accessibility services
+ val description =
+ if (passwordVisible) "Hide password" else "Show password"
+
+ IconButton(onClick = {
+ passwordVisible = !passwordVisible
+ }) {
+ Icon(imageVector = image, description)
+ }
+ },
+ modifier = modifier
+ )
+}
\ No newline at end of file
diff --git a/app/src/main/java/com/porg/gugal/MainActivity.kt b/app/src/main/java/com/porg/gugal/ui/MainActivity.kt
similarity index 53%
rename from app/src/main/java/com/porg/gugal/MainActivity.kt
rename to app/src/main/java/com/porg/gugal/ui/MainActivity.kt
index 3a4bc6d8e5d835aae777113397580a11abfb5e8a..feaaed05297a3bb6715931622d6c7e9fecdec78d 100644
--- a/app/src/main/java/com/porg/gugal/MainActivity.kt
+++ b/app/src/main/java/com/porg/gugal/ui/MainActivity.kt
@@ -1,399 +1,580 @@
-/*
- * MainActivity.kt
- * Gugal
- * Copyright (c) 2021 thegreatporg
- *
- * This program is free software: you can redistribute it and/or modify
- * it under the terms of the GNU General Public License as published by
- * the Free Software Foundation, either version 3 of the License, or
- * (at your option) any later version.
- *
- * This program is distributed in the hope that it will be useful,
- * but WITHOUT ANY WARRANTY; without even the implied warranty of
- * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- * GNU General Public License for more details.
- *
- * You should have received a copy of the GNU General Public License
- * along with this program. If not, see .
- */
-
-package com.porg.gugal
-
-import android.app.Activity
-import android.content.Intent
-import android.os.Bundle
-import android.util.Log
-import androidx.activity.ComponentActivity
-import androidx.activity.compose.setContent
-import androidx.compose.animation.ExperimentalAnimationApi
-import androidx.compose.foundation.layout.*
-import androidx.compose.material.ExperimentalMaterialApi
-import androidx.compose.material.Scaffold
-import androidx.compose.material3.*
-import androidx.compose.material3.windowsizeclass.ExperimentalMaterial3WindowSizeClassApi
-import androidx.compose.material3.windowsizeclass.WindowSizeClass
-import androidx.compose.material3.windowsizeclass.WindowWidthSizeClass
-import androidx.compose.material3.windowsizeclass.calculateWindowSizeClass
-import androidx.compose.runtime.Composable
-import androidx.compose.runtime.getValue
-import androidx.compose.ui.Alignment
-import androidx.compose.ui.Modifier
-import androidx.compose.ui.graphics.Color
-import androidx.compose.ui.graphics.vector.ImageVector
-import androidx.compose.ui.platform.testTag
-import androidx.compose.ui.res.vectorResource
-import androidx.compose.ui.unit.dp
-import androidx.core.content.ContextCompat
-import androidx.core.view.WindowCompat
-import androidx.navigation.NavGraph.Companion.findStartDestination
-import androidx.navigation.NavHostController
-import androidx.navigation.compose.NavHost
-import androidx.navigation.compose.composable
-import androidx.navigation.compose.currentBackStackEntryAsState
-import androidx.navigation.compose.rememberNavController
-import com.google.gson.Gson
-import com.porg.gugal.Global.Companion.fullSearchHistory
-import com.porg.gugal.Global.Companion.maxSearchHistoryLength
-import com.porg.gugal.Global.Companion.serpProvider
-import com.porg.gugal.Global.Companion.sharedPreferences
-import com.porg.gugal.Global.Companion.visibleSearchHistory
-import com.porg.gugal.news.NewsPage
-import com.porg.gugal.providers.exceptions.InvalidCredentialException
-import com.porg.gugal.setup.SetupNewStartActivity
-import com.porg.gugal.setup.SetupStartActivity
-import com.porg.gugal.ui.ResultPage
-import com.porg.gugal.ui.SettingsPage
-import com.porg.gugal.ui.theme.GugalTheme
-import org.json.JSONArray
-
-@ExperimentalAnimationApi
-@ExperimentalMaterialApi
-class MainActivity : ComponentActivity() {
-
- private var apikey = ""
- private var cx = ""
- private var ca: List? = null
- private lateinit var context: Activity
- private var invalidCredentials: Boolean = false
-
- private var showLoadOldPrefsAlert = false
- private var showSetup = false
- private var searchQuery = ""
-
- @ExperimentalAnimationApi
- @OptIn(ExperimentalMaterial3WindowSizeClassApi::class)
- override fun onCreate(savedInstanceState: Bundle?) {
- super.onCreate(savedInstanceState)
- WindowCompat.setDecorFitsSystemWindows(window, false)
-
- context = this
-
- // Retrieve shared search query
- val query = intent.getStringExtra("query")
- var sender = ""
- if (referrer != null) sender = referrer!!.host.toString()
- // Don't allow queries from other apps
- if (query != null && sender == applicationContext.packageName) {
- searchQuery = query
- }
-
- // Create global sharedPreferences object
- Global.createSharedPreferences(applicationContext)
-
- // Load SERP provider
- loadSerp()
- // Load search history
- loadPastSearches()
- // Update some experiments
- Global.gugalNews.enabled = sharedPreferences.getBoolean("newsPreview", false)
- // Show setup wizard if necessary
- if (showSetup) {
- showSetup = false
- startActivity(Intent(applicationContext, SetupNewStartActivity::class.java))
- return
- }
-
- ca = loadCxApi()
- if (serpProvider.id.endsWith("-goog") && (ca?.size ?: 0) == 2) {
- if ((ca?.get(0) ?: "none") != "none") cx = ca?.get(0) ?: ""
- if ((ca?.get(1) ?: "none") != "none") apikey = ca?.get(1) ?: ""
- }
-
- var startIntent: Intent? = null
- val noCredentials = ca.isNullOrEmpty() || invalidCredentials
- // the changelog shouldn't be shown on first run
- if (noCredentials && serpProvider.providerInfo?.requiresSetup ?: noCredentials) {
- startIntent = Intent(context, SetupStartActivity::class.java)
- } else if (shouldShowChangelog()) {
- startIntent = Intent(context, ChangelogActivity::class.java)
- }
- if (startIntent != null) {
- startIntent.flags = Intent.FLAG_ACTIVITY_NEW_TASK
- ContextCompat.startActivity(context, startIntent, null)
- }
-
- val simple = resources.getBoolean(R.bool.isSimple) || intent.getBooleanExtra("simple", false)
- setContent {
- val windowSizeClass = calculateWindowSizeClass(this)
- GugalTheme {
- if (showLoadOldPrefsAlert) {
- Column(modifier = Modifier.fillMaxWidth().fillMaxHeight(),
- verticalArrangement = Arrangement.Center,
- horizontalAlignment = Alignment.CenterHorizontally) {
- Text("Updating save data...", Modifier.padding(bottom = 16.dp))
- LinearProgressIndicator()
- }
- resaveGoogleNonSerpPrefs(cx, apikey)
- } else {
- // A surface container using the 'background' color from the theme
- Surface(
- color = MaterialTheme.colorScheme.background
- ) {
- if (simple)
- ResultPage(
- context, searchQuery,
- windowSizeClass = windowSizeClass
- )
- else {
- val navController = rememberNavController()
- // If the device is a phone in vertical, use a vertical navigation bar.
- if (windowSizeClass.widthSizeClass == WindowWidthSizeClass.Compact) {
- Scaffold(
- content = { padding ->
- NavigationHost(
- navController = navController,
- padding = padding,
- sizeClass = windowSizeClass
- )
- },
- bottomBar = { BottomNavigationBar(navController = navController) },
- backgroundColor = Color.Transparent
- )
- }
- // Otherwise use a navigation rail.
- else {
- Row(modifier = Modifier.systemBarsPadding()){
- SideNavigationRail(navController)
- NavigationHost(
- navController = navController,
- padding = PaddingValues(5.dp),
- sizeClass = windowSizeClass
- )
- }
- }
- }
- }
- }
- }
- }
- }
-
-
- private fun shouldShowChangelog(): Boolean {
- try {
- // if there is no lastGugalVersion preference the last version is older than 0.5
- if (!sharedPreferences.contains("lastGugalVersion")) {
- with(sharedPreferences.edit()) {
- this.putInt("lastGugalVersion", BuildConfig.VERSION_CODE)
- apply()
- return true
- }
- }
-
- if (sharedPreferences.getInt("lastGugalVersion",
- BuildConfig.VERSION_CODE) < BuildConfig.VERSION_CODE
- ) {
- with(sharedPreferences.edit()) {
- this.putInt("lastGugalVersion", BuildConfig.VERSION_CODE)
- apply()
- return true
- }
- } else return false
- }
- // if any errors occur reading/writing to the preferences, don't show the changelog
- catch (exc: Exception) {
- return false
- }
- }
-
- // Loads the SERP provider.
- private fun loadSerp() {
- val serpID = sharedPreferences.getString("serp", "none")
- Log.d("gugal", "loadSerp: serp ID is $serpID")
-
- if (serpID != "none") Global.setSerpProvider(serpID!!)
- else showSetup = true
- }
-
- // Loads the search history.
- fun loadPastSearches() {
- // Load the full search history
- val ps = sharedPreferences.getStringSet("pastSearches", mutableSetOf())
- if (ps != null) fullSearchHistory = ps.toMutableList()
- // Try to load visible search history
- val vpsj = sharedPreferences.getString("visiblePastSearches", "[]")
- if (vpsj != "[]") {
- val vps = JSONArray(vpsj)
- visibleSearchHistory = vps.toMutableStringList()
- } else {
- // If there is no visible history, load the first N from the full history and save them
- visibleSearchHistory =
- if (fullSearchHistory.count() < maxSearchHistoryLength) fullSearchHistory
- else fullSearchHistory.subList(0, maxSearchHistoryLength)
- with (sharedPreferences.edit()) {
- this.putString("visiblePastSearches", Gson().toJson(visibleSearchHistory))
- apply()
- }
- }
- }
-
- private fun JSONArray.toMutableStringList(): MutableList = MutableList(length(), this::getString)
-
- private fun resaveGoogleNonSerpPrefs(cx: String, apikey: String) {
- with (sharedPreferences.edit()) {
- // Edit the user's shared preferences...
- this.putString("serp_${serpProvider.id}_cx", cx)
- this.putString("serp_${serpProvider.id}_ak", apikey)
- // ...and remove the old preferences
- this.remove("serp_google_data_cx")
- this.remove("serp_google_data_ak")
- apply()
- }
- showLoadOldPrefsAlert = false
- }
-
- private fun loadCxApi(): List {
- val arr: ArrayList = ArrayList()
- // load pre-0.4 prefs if the SERP provider is google and they exist
- if (sharedPreferences.getString("serp_google_data_cx", "none") != "none" && serpProvider.id.endsWith("-goog")) {
- sharedPreferences.getString("serp_google_data_cx", "none")?.let { arr.add(it) }
- sharedPreferences.getString("serp_google_data_ak", "none")?.let { arr.add(it) }
- showLoadOldPrefsAlert = true
- return arr.toList()
- }
-
- val crdMap: MutableMap = mutableMapOf()
- // request the sensitive cred names from the SERP provider and load them
- for (name in serpProvider.getSensitiveCredentialNames())
- sharedPreferences.getString("serp_${serpProvider.id}_${name}", "none")?.let { if (it != "none") {arr.add(it); crdMap[name] = it} }
- // pass the credentials to the SERP provider
- try {
- serpProvider.useSensitiveCredentials(crdMap)
- }
- // if the SERP provider notifies the app that they are incorrect, re-run setup
- catch (x: InvalidCredentialException) {
- Log.w("Gugal", "Received invalid credentials, should re-run setup.")
- invalidCredentials = true
- }
- // if the SERP provider gives any other exception, it's broken and we should ask for another one
- catch (x: Exception) {
- Log.w("Gugal", "Received exception, should re-run setup.")
- invalidCredentials = true
- }
- return arr.toList()
- }
-
- @Composable
- fun BottomNavigationBar(navController: NavHostController) {
-
- NavigationBar {
- val backStackEntry by navController.currentBackStackEntryAsState()
- val currentRoute = backStackEntry?.destination?.route
-
- var navItems = NavBarItems.BarItems
- if (Global.gugalNews.enabled) navItems = NavBarItems.BarItemsWithNews
-
- navItems.forEach { navItem ->
-
- NavigationBarItem(
- selected = currentRoute == navItem.route,
- onClick = {
- navController.navigate(navItem.route) {
- popUpTo(navController.graph.findStartDestination().id) {
- saveState = true
- }
- launchSingleTop = true
- restoreState = true
- }
- },
- modifier = Modifier.testTag("MA_NI_" + navItem.route),
-
- icon = {
- Icon(imageVector = ImageVector.vectorResource(navItem.image),
- contentDescription = getText(navItem.title).toString())
- },
- label = {
- Text(text = getText(navItem.title).toString())
- },
- )
- }
- }
- }
- @Composable
- fun SideNavigationRail(navController: NavHostController) {
-
- NavigationRail {
- val backStackEntry by navController.currentBackStackEntryAsState()
- val currentRoute = backStackEntry?.destination?.route
-
- var navItems = NavBarItems.BarItems
- if (Global.gugalNews.enabled) navItems = NavBarItems.BarItemsWithNews
-
- navItems.forEach { navItem ->
-
- NavigationRailItem(
- selected = currentRoute == navItem.route,
- onClick = {
- navController.navigate(navItem.route) {
- popUpTo(navController.graph.findStartDestination().id) {
- saveState = true
- }
- launchSingleTop = true
- restoreState = true
- }
- },
-
- icon = {
- Icon(imageVector = ImageVector.vectorResource(navItem.image),
- contentDescription = getText(navItem.title).toString())
- },
- label = {
- Text(text = getText(navItem.title).toString())
- },
-
- modifier = Modifier.padding(5.dp)
- )
- }
- }
- }
-
- @Composable
- fun NavigationHost(
- navController: NavHostController,
- padding: PaddingValues,
- modifier: Modifier = Modifier,
- sizeClass: WindowSizeClass
- ) {
- NavHost(
- navController = navController,
- startDestination = Routes.Search.route,
- modifier = Modifier.padding(padding).statusBarsPadding().then(modifier)
- ) {
- composable(Routes.Search.route) {
- ResultPage(
- context,
- searchQuery,
- windowSizeClass = sizeClass
- )
- }
-
- composable(Routes.Settings.route) {
- SettingsPage(context)
- }
-
- composable(Routes.News.route) {
- NewsPage(context)
- }
- }
- }
+/*
+ * MainActivity.kt
+ * Gugal
+ * Copyright (c) 2024-2025 the Gugal maintainers
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ */
+
+package com.porg.gugal.ui
+
+import android.app.Activity
+import android.content.Intent
+import android.net.ConnectivityManager
+import android.net.Network
+import android.net.NetworkCapabilities
+import android.net.NetworkRequest
+import android.os.Build
+import android.os.Bundle
+import android.util.Log
+import androidx.activity.ComponentActivity
+import androidx.activity.compose.setContent
+import androidx.compose.animation.AnimatedVisibility
+import androidx.compose.animation.ExperimentalAnimationApi
+import androidx.compose.foundation.background
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.PaddingValues
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.WindowInsets
+import androidx.compose.foundation.layout.fillMaxHeight
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.navigationBars
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.statusBars
+import androidx.compose.foundation.layout.statusBarsPadding
+import androidx.compose.foundation.layout.systemBarsPadding
+import androidx.compose.foundation.layout.windowInsetsPadding
+import androidx.compose.material.ExperimentalMaterialApi
+import androidx.compose.material3.Icon
+import androidx.compose.material3.LinearProgressIndicator
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.NavigationBar
+import androidx.compose.material3.NavigationBarItem
+import androidx.compose.material3.NavigationRail
+import androidx.compose.material3.NavigationRailItem
+import androidx.compose.material3.Scaffold
+import androidx.compose.material3.Surface
+import androidx.compose.material3.Text
+import androidx.compose.material3.windowsizeclass.ExperimentalMaterial3WindowSizeClassApi
+import androidx.compose.material3.windowsizeclass.WindowSizeClass
+import androidx.compose.material3.windowsizeclass.WindowWidthSizeClass
+import androidx.compose.material3.windowsizeclass.calculateWindowSizeClass
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.DisposableEffect
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.graphics.vector.ImageVector
+import androidx.compose.ui.platform.LocalContext
+import androidx.compose.ui.platform.testTag
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.res.vectorResource
+import androidx.compose.ui.text.style.TextAlign
+import androidx.compose.ui.unit.dp
+import androidx.core.view.WindowCompat
+import androidx.navigation.NavGraph.Companion.findStartDestination
+import androidx.navigation.NavHostController
+import androidx.navigation.compose.NavHost
+import androidx.navigation.compose.composable
+import androidx.navigation.compose.currentBackStackEntryAsState
+import androidx.navigation.compose.rememberNavController
+import com.google.gson.Gson
+import com.porg.gugal.BuildConfig
+import com.porg.gugal.BuildInfo
+import com.porg.gugal.Global
+import com.porg.gugal.Global.Companion.fullSearchHistory
+import com.porg.gugal.Global.Companion.maxSearchHistoryLength
+import com.porg.gugal.Global.Companion.serpProvider
+import com.porg.gugal.Global.Companion.sharedPreferences
+import com.porg.gugal.Global.Companion.visibleSearchHistory
+import com.porg.gugal.GugalApplication
+import com.porg.gugal.R
+import com.porg.gugal.data.container.Experiment
+import com.porg.gugal.news.NewsPage
+import com.porg.gugal.providers.Credential
+import com.porg.gugal.providers.exceptions.InvalidCredentialException
+import com.porg.gugal.setup.SetupNewStartActivity
+import com.porg.gugal.setup.SetupSelectSerpActivity
+import com.porg.gugal.ui.nav.NavBarItems
+import com.porg.gugal.ui.nav.Routes
+import com.porg.gugal.ui.theme.GugalTheme
+import org.json.JSONArray
+import androidx.core.content.edit
+import com.porg.gugal.ui.expressive.ExSettingsPage
+
+@ExperimentalAnimationApi
+@ExperimentalMaterialApi
+class MainActivity : ComponentActivity() {
+
+ private var apikey = ""
+ private var cx = ""
+ private var creds: List? = null
+ private lateinit var context: Activity
+ private var invalidCredentials: Boolean = false
+
+ private var showLoadOldPrefsAlert = false
+ private var showSetup = false
+ private var showSerpSelector = false
+ private var autoSelectSearch = false
+ private var searchQuery = ""
+
+ // Define that Gugal needs a network:
+ val networkSpecRequest = NetworkRequest.Builder()
+ // that can access the Internet...
+ .addCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET)
+ // ...using either Wi-Fi or mobile data
+ .addTransportType(NetworkCapabilities.TRANSPORT_WIFI)
+ .addTransportType(NetworkCapabilities.TRANSPORT_CELLULAR)
+ .build()
+
+ @ExperimentalAnimationApi
+ @OptIn(ExperimentalMaterial3WindowSizeClassApi::class)
+ override fun onCreate(savedInstanceState: Bundle?) {
+ super.onCreate(savedInstanceState)
+ WindowCompat.setDecorFitsSystemWindows(window, false)
+
+ context = this
+
+ // Retrieve shared search query
+ val query = intent.getStringExtra("query")
+ var sender = ""
+ if (referrer != null) sender = referrer!!.host.toString()
+ // Don't allow queries from other apps
+ if (query != null && sender == applicationContext.packageName) {
+ searchQuery = query
+ }
+
+ // Check if the search field should be pre-selected.
+ autoSelectSearch = intent.getBooleanExtra("autoSelectSearch", false)
+
+ // Create global sharedPreferences object
+ Global.createSharedPreferences(applicationContext)
+
+ // Load SERP provider
+ loadSerp()
+ // Load search history
+ loadPastSearches()
+ val experiments = (application as GugalApplication).container.experiments
+ // Update some experiments
+ experiments[Experiment.ID_GUGAL_NEWS]?.enabled = sharedPreferences.getBoolean("newsPreview", false)
+ // Show setup wizard if necessary
+ if (showSetup) {
+ showSetup = false
+ startActivity(Intent(applicationContext, SetupNewStartActivity::class.java))
+ return
+ }
+ if (showSerpSelector) {
+ showSerpSelector = false
+ val serpSelector = Intent(applicationContext, SetupSelectSerpActivity::class.java)
+ startActivity(serpSelector)
+ return
+ }
+
+ creds = loadCredentials()
+ if (serpProvider.id.endsWith("-goog") && (creds?.size ?: 0) == 2) {
+ if ((creds?.get(0) ?: "none") != "none") cx = creds?.get(0) ?: ""
+ if ((creds?.get(1) ?: "none") != "none") apikey = creds?.get(1) ?: ""
+ }
+
+ var startIntent: Intent? = null
+ val noCredentials = creds.isNullOrEmpty() || invalidCredentials
+ // the changelog shouldn't be shown on first run
+ if (noCredentials && serpProvider.providerInfo?.requiresSetup ?: noCredentials) {
+ startIntent = Intent(context, SetupNewStartActivity::class.java)
+ } else if (shouldShowChangelog()) {
+ startIntent = Intent(context, ChangelogActivity::class.java)
+ }
+ if (startIntent != null) {
+ startIntent.flags = Intent.FLAG_ACTIVITY_NEW_TASK
+ context.startActivity(startIntent, null)
+ finish()
+ }
+
+ // Get the system connectivity manager
+ val connectivityManager = getSystemService(ConnectivityManager::class.java) as ConnectivityManager
+
+ Log.d("gugal",
+ (application as GugalApplication).container.experiments[Experiment.ID_EXPRESSIVE].toString()
+ )
+
+ setContent {
+ val windowSizeClass = calculateWindowSizeClass(this)
+
+ var internetAvailable by remember {
+ mutableStateOf(true)
+ }
+ // Create an internet monitor
+ InternetMonitor(connectivityManager) {
+ internetAvailable = it
+ }
+
+ GugalTheme {
+ if (showLoadOldPrefsAlert) {
+ Column(modifier = Modifier
+ .fillMaxWidth()
+ .fillMaxHeight(),
+ verticalArrangement = Arrangement.Center,
+ horizontalAlignment = Alignment.CenterHorizontally) {
+ Text("Updating save data...", Modifier.padding(bottom = 16.dp))
+ LinearProgressIndicator()
+ }
+ resaveGoogleNonSerpPrefs(cx, apikey)
+ } else {
+ // A surface container using the 'background' color from the theme
+ Surface(
+ color = MaterialTheme.colorScheme.background
+ ) {
+ val navController = rememberNavController()
+ // If the device is a phone in vertical, use a vertical navigation bar.
+ if (windowSizeClass.widthSizeClass == WindowWidthSizeClass.Compact) {
+ Scaffold(
+ contentWindowInsets = WindowInsets.navigationBars,
+ content = { padding ->
+ NavigationHost(
+ navController = navController,
+ padding = padding,
+ sizeClass = windowSizeClass,
+ backupEnabled = experiments[Experiment.ID_BACKUP]?.enabled ?: false
+ )
+ },
+ bottomBar = { BottomNavigationBar(navController = navController) },
+ topBar = {
+ AnimatedVisibility(visible = !internetAvailable) {
+ Text(
+ stringResource(id = R.string.no_inet),
+ Modifier
+ .background(MaterialTheme.colorScheme.secondaryContainer)
+ .windowInsetsPadding(WindowInsets.statusBars)
+ .fillMaxWidth()
+ .padding(10.dp),
+ textAlign = TextAlign.Center
+ )
+ }
+ }
+ )
+ }
+ // Otherwise use a navigation rail.
+ else {
+ Row(modifier = Modifier.systemBarsPadding()){
+ SideNavigationRail(navController)
+ NavigationHost(
+ navController = navController,
+ padding = PaddingValues(5.dp),
+ sizeClass = windowSizeClass
+ )
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+
+
+ /**
+ * A Composable that monitors the current internet connection.
+ */
+ @Composable
+ private fun InternetMonitor(
+ connectivityManager: ConnectivityManager,
+ setStatus: (Boolean) -> Unit
+ ) {
+ val networkCallback = remember {
+ object : ConnectivityManager.NetworkCallback() {
+ // network is available for use
+ override fun onAvailable(network: Network) {
+ super.onAvailable(network)
+ Log.d("gugal#im", "network is available")
+ setStatus(true)
+ }
+
+ override fun onCapabilitiesChanged(
+ network: Network,
+ networkCapabilities: NetworkCapabilities
+ ) {
+ super.onCapabilitiesChanged(network, networkCapabilities)
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
+ Log.d("gugal#im", networkCapabilities.signalStrength.toString())
+ }
+
+ }
+
+ // lost network connection
+ override fun onLost(network: Network) {
+ super.onLost(network)
+ Log.d("gugal#im", "network is unavailable")
+ setStatus(false)
+ }
+ }
+ }
+ LaunchedEffect(LocalContext.current) {
+ // Listen to changes in the network
+ connectivityManager.registerNetworkCallback(
+ networkSpecRequest,
+ networkCallback
+ )
+ Log.d("gugal#im", "registered")
+ }
+
+ // When this composable is disposed, unregister the callback
+ DisposableEffect(key1 = Unit) {
+ onDispose {
+ connectivityManager.unregisterNetworkCallback(networkCallback)
+ Log.d("gugal#im", "unregistered")
+ }
+ }
+ }
+
+ private fun shouldShowChangelog(): Boolean {
+ // if this is a test version, skip all checks and don't show it
+ if (BuildInfo.isTest()) {
+ return false
+ }
+ try {
+ // if there is no lastGugalVersion preference the last version is older than 0.5
+ if (!sharedPreferences.contains("lastGugalVersion")) {
+ with(sharedPreferences.edit()) {
+ this.putInt("lastGugalVersion", BuildConfig.VERSION_CODE)
+ apply()
+ return true
+ }
+ }
+
+ if (sharedPreferences.getInt("lastGugalVersion",
+ BuildConfig.VERSION_CODE) < BuildConfig.VERSION_CODE
+ ) {
+ with(sharedPreferences.edit()) {
+ this.putInt("lastGugalVersion", BuildConfig.VERSION_CODE)
+ apply()
+ return true
+ }
+ } else return false
+ }
+ // if any errors occur reading/writing to the preferences, don't show the changelog
+ catch (exc: Exception) {
+ return false
+ }
+ }
+
+ // Loads the SERP provider.
+ private fun loadSerp() {
+ val serpID = sharedPreferences.getString("serp", "none")
+ Log.d("gugal", "loadSerp: serp ID is $serpID")
+
+ try {
+ if (serpID != "none") Global.setSerpProvider(serpID!!)
+ else showSetup = true
+ } catch (e: IllegalArgumentException) {
+ showSerpSelector = true
+ }
+ }
+
+ // Loads the search history.
+ fun loadPastSearches() {
+ // Load the full search history
+ val ps = sharedPreferences.getStringSet("pastSearches", mutableSetOf())
+ if (ps != null) fullSearchHistory = ps.toMutableList()
+ // Try to load visible search history
+ val vpsj = sharedPreferences.getString("visiblePastSearches", "[]")
+ if (vpsj != "[]") {
+ val vps = JSONArray(vpsj)
+ visibleSearchHistory = vps.toMutableStringList()
+ } else {
+ // If there is no visible history, load the first N from the full history and save them
+ visibleSearchHistory =
+ if (fullSearchHistory.count() < maxSearchHistoryLength) fullSearchHistory
+ else fullSearchHistory.subList(0, maxSearchHistoryLength)
+ sharedPreferences.edit {
+ this.putString("visiblePastSearches", Gson().toJson(visibleSearchHistory))
+ }
+ }
+ }
+
+ private fun JSONArray.toMutableStringList(): MutableList = MutableList(length(), this::getString)
+
+ private fun resaveGoogleNonSerpPrefs(cx: String, apikey: String) {
+ sharedPreferences.edit {
+ // Edit the user's shared preferences...
+ this.putString("serp_${serpProvider.id}_cx", cx)
+ this.putString("serp_${serpProvider.id}_ak", apikey)
+ // ...and remove the old preferences
+ this.remove("serp_google_data_cx")
+ this.remove("serp_google_data_ak")
+ }
+ showLoadOldPrefsAlert = false
+ }
+
+ private fun loadCredentials(): List {
+ val arr: ArrayList = ArrayList()
+ // load pre-0.4 prefs if the SERP provider is google and they exist
+ if (sharedPreferences.getString("serp_google_data_cx", "none") != "none" && serpProvider.id.endsWith("-goog")) {
+ sharedPreferences.getString("serp_google_data_cx", "none")?.let { arr.add(it) }
+ sharedPreferences.getString("serp_google_data_ak", "none")?.let { arr.add(it) }
+ showLoadOldPrefsAlert = true
+ return arr.toList()
+ }
+
+ val crdMap: MutableMap = mutableMapOf()
+ // request the sensitive cred names from the SERP provider and load them
+ for (cred in serpProvider.getSensitiveCredentialNames())
+ sharedPreferences.getString("serp_${serpProvider.id}_${cred.id}", "none")?.let { if (it != "none") {arr.add(it); crdMap[cred] = it} }
+ // pass the credentials to the SERP provider
+ try {
+ serpProvider.useSensitiveCredentials(crdMap)
+ }
+ // if the SERP provider notifies the app that they are incorrect, re-run setup
+ catch (x: InvalidCredentialException) {
+ Log.w("Gugal", "Received invalid credentials, should re-run setup.")
+ invalidCredentials = true
+ }
+ // if the SERP provider gives any other exception, it's broken and we should ask for another one
+ catch (x: Exception) {
+ Log.w("Gugal", "Received exception, should re-run setup.")
+ invalidCredentials = true
+ }
+ return arr.toList()
+ }
+
+ override fun onNewIntent(intent: Intent) {
+ super.onNewIntent(intent)
+ this.intent = intent
+
+ autoSelectSearch = intent.getBooleanExtra("autoSelectSearch", false)
+ }
+
+ @Composable
+ fun BottomNavigationBar(navController: NavHostController) {
+ val gugalNewsExperiment by remember {mutableStateOf(
+ (application as GugalApplication).container.experiments[Experiment.ID_GUGAL_NEWS]
+ )}
+ val m3eExperiment by remember {mutableStateOf(
+ (application as GugalApplication).container.experiments[Experiment.ID_EXPRESSIVE]
+ )}
+
+ NavigationBar {
+ val backStackEntry by navController.currentBackStackEntryAsState()
+ val currentRoute = backStackEntry?.destination?.route
+
+ var navItems = NavBarItems.BarItems
+ if (gugalNewsExperiment?.enabled == true) navItems = NavBarItems.BarItemsWithNews
+ if (m3eExperiment?.enabled == true) navItems = NavBarItems.BarItemsExpressive
+
+ navItems.forEach { navItem ->
+
+ NavigationBarItem(
+ selected = currentRoute == navItem.route,
+ onClick = {
+ navController.navigate(navItem.route) {
+ popUpTo(navController.graph.findStartDestination().id) {
+ saveState = true
+ }
+ launchSingleTop = true
+ restoreState = true
+ }
+ },
+ modifier = Modifier.testTag("MA_NI_" + navItem.route),
+
+ icon = {
+ Icon(imageVector = ImageVector.vectorResource(navItem.image),
+ contentDescription = getText(navItem.title).toString())
+ },
+ label = {
+ Text(text = getText(navItem.title).toString())
+ },
+ )
+ }
+ }
+ }
+ @Composable
+ fun SideNavigationRail(navController: NavHostController) {
+ val gugalNewsExperiment by remember {mutableStateOf(
+ (application as GugalApplication).container.experiments[Experiment.ID_GUGAL_NEWS]
+ )}
+ val m3eExperiment by remember {mutableStateOf(
+ (application as GugalApplication).container.experiments[Experiment.ID_EXPRESSIVE]
+ )}
+
+ NavigationRail {
+ val backStackEntry by navController.currentBackStackEntryAsState()
+ val currentRoute = backStackEntry?.destination?.route
+
+ var navItems = NavBarItems.BarItems
+ if (gugalNewsExperiment?.enabled == true) navItems = NavBarItems.BarItemsWithNews
+ if (m3eExperiment?.enabled == true) navItems = NavBarItems.BarItemsExpressive
+
+ navItems.forEach { navItem ->
+
+ NavigationRailItem(
+ selected = currentRoute == navItem.route,
+ onClick = {
+ navController.navigate(navItem.route) {
+ popUpTo(navController.graph.findStartDestination().id) {
+ saveState = true
+ }
+ launchSingleTop = true
+ restoreState = true
+ }
+ },
+
+ icon = {
+ Icon(imageVector = ImageVector.vectorResource(navItem.image),
+ contentDescription = getText(navItem.title).toString())
+ },
+ label = {
+ Text(text = getText(navItem.title).toString())
+ },
+
+ modifier = Modifier.padding(5.dp)
+ )
+ }
+ }
+ }
+
+ @Composable
+ fun NavigationHost(
+ navController: NavHostController,
+ padding: PaddingValues,
+ modifier: Modifier = Modifier,
+ sizeClass: WindowSizeClass,
+ backupEnabled: Boolean = false
+ ) {
+ NavHost(
+ navController = navController,
+ startDestination = Routes.Search.route,
+ modifier = Modifier
+ .padding(padding)
+ .statusBarsPadding()
+ .then(modifier)
+ ) {
+ composable(Routes.Search.route) {
+ ResultPage(
+ context,
+ searchQuery,
+ autoSelectSearch,
+ windowSizeClass = sizeClass,
+ demoMode = (application as GugalApplication).container.demoMode,
+ bottomSearchField = sharedPreferences.getBoolean(Experiment.ID_BOTTOM_SEARCH, false),
+ m3eMode = (application as GugalApplication).container.experiments[Experiment.ID_EXPRESSIVE]?.enabled == true
+ )
+ }
+
+ composable(Routes.Settings.route) {
+ SettingsPage(context, backupEnabled)
+ }
+
+ composable(Routes.ExSettings.route) {
+ ExSettingsPage(context, backupEnabled)
+ }
+
+ composable(Routes.News.route) {
+ NewsPage(context, demoMode = (application as GugalApplication).container.demoMode)
+ }
+ }
+ }
}
\ No newline at end of file
diff --git a/app/src/main/java/com/porg/gugal/ui/ResultsPage.kt b/app/src/main/java/com/porg/gugal/ui/ResultsPage.kt
index 59bb3268b6d55b6f05bdd58ca7e2912a69c1bfe1..31c3cf43450a8e4515bf5f33865b047fd9efb377 100644
--- a/app/src/main/java/com/porg/gugal/ui/ResultsPage.kt
+++ b/app/src/main/java/com/porg/gugal/ui/ResultsPage.kt
@@ -1,7 +1,7 @@
/*
* ResultsPage.kt
* Gugal
- * Copyright (c) 2023 thegreatporg
+ * Copyright (c) 2023-2025 the Gugal maintainers
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
@@ -22,19 +22,42 @@ package com.porg.gugal.ui
import android.app.Activity
import android.content.Intent
import android.content.Intent.FLAG_ACTIVITY_NEW_TASK
-import androidx.compose.animation.*
-import androidx.compose.foundation.clickable
+import android.util.Log
+import androidx.compose.animation.AnimatedVisibility
+import androidx.compose.animation.ExperimentalAnimationApi
+import androidx.compose.animation.fadeIn
+import androidx.compose.animation.fadeOut
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.interaction.PressInteraction
-import androidx.compose.foundation.layout.*
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.fillMaxHeight
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions
-import androidx.compose.material3.*
+import androidx.compose.material3.Button
+import androidx.compose.material3.Icon
+import androidx.compose.material3.IconButton
+import androidx.compose.material3.LinearProgressIndicator
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Scaffold
+import androidx.compose.material3.Surface
+import androidx.compose.material3.Text
+import androidx.compose.material3.TextField
+import androidx.compose.material3.TextFieldDefaults
import androidx.compose.material3.windowsizeclass.WindowSizeClass
-import androidx.compose.runtime.*
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.collectAsState
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusRequester
@@ -43,17 +66,12 @@ import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
-import androidx.compose.ui.semantics.CustomAccessibilityAction
-import androidx.compose.ui.semantics.clearAndSetSemantics
-import androidx.compose.ui.semantics.customActions
-import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.text.input.ImeAction
-import androidx.compose.ui.text.style.TextAlign
-import androidx.compose.ui.text.style.TextOverflow
+import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
-import androidx.core.content.ContextCompat.startActivity
import androidx.lifecycle.viewmodel.compose.viewModel
import com.google.gson.Gson
+import com.porg.gugal.DemoMode
import com.porg.gugal.Global
import com.porg.gugal.Global.Companion.fullSearchHistory
import com.porg.gugal.Global.Companion.maxSearchHistoryLength
@@ -65,22 +83,29 @@ import com.porg.gugal.devopt.DevOptMainActivity
import com.porg.gugal.providers.responses.ErrorResponse
import com.porg.gugal.providers.responses.InvalidCredentialResponse
import com.porg.gugal.setup.SetupConfigureSerpActivity
-import com.porg.gugal.ui.Components.Companion.GugalLogo
-import com.porg.gugal.ui.Components.Companion.Results
+import com.porg.gugal.ui.components.GugalLogo
+import com.porg.gugal.ui.components.PastSearch
+import com.porg.gugal.ui.components.Results
+import com.porg.gugal.ui.components.SearchErrorMessage
import com.porg.gugal.ui.theme.shapeScheme
+import com.porg.gugal.viewmodel.ResultState
import com.porg.gugal.viewmodel.ResultViewModel
+import tech.cataspect.m3x.materialListItems
val cardElevation = 15.dp
var lastSearchQuery = ""
-@OptIn(ExperimentalMaterial3Api::class)
@ExperimentalAnimationApi
@Composable
fun ResultPage(
context: Activity,
searchQuery: String,
- resultModel: ResultViewModel = viewModel(),
- windowSizeClass: WindowSizeClass
+ autoSelectSearch: Boolean,
+ resultModel: ResultViewModel = viewModel(factory = ResultViewModel.Factory),
+ windowSizeClass: WindowSizeClass,
+ demoMode: Boolean = false,
+ m3eMode: Boolean = false,
+ bottomSearchField: Boolean = false
) {
val composeDone = remember { mutableStateOf(false) }
val resultState by resultModel.uiState.collectAsState()
@@ -88,160 +113,225 @@ fun ResultPage(
resultModel.updateSizeClass(windowSizeClass.widthSizeClass)
- Column {
- val error = remember { mutableStateOf(null) }
- val errorResponse by error
+ val tfSidePadding = if (resultState.showLogo) 14.dp else 4.dp
+ val tfFocusRequester = remember { FocusRequester() }
- // top card
- AnimatedVisibility (resultState.showLogo) {
- Box(
- modifier = Modifier.fillMaxWidth()
- ) {
- GugalLogo(modifier = Modifier.align(alignment = Alignment.Center))
+ Scaffold(
+ bottomBar = {
+ if (bottomSearchField) {
+ SearchField(resultModel, tfSidePadding, tfFocusRequester, context, resultState, bottomSearchField)
}
}
-
- // Automatically handle some error responses
- if (errorResponse != null) {
- when (errorResponse) {
- // If an InvalidCredentialResponse is received, open the setup page
- is InvalidCredentialResponse -> {
- val intent = Intent(context, SetupConfigureSerpActivity::class.java)
- intent.putExtra("launchedFromSettings", true)
- startActivity(context, intent, null)
- error.value = null
+ ) { padding ->
+ Column(modifier = Modifier.padding(padding)) {
+ // top card
+ AnimatedVisibility(resultState.showLogo) {
+ Box(
+ modifier = Modifier.fillMaxWidth()
+ ) {
+ GugalLogo(modifier = Modifier.align(alignment = Alignment.Center))
}
}
- }
- val tfSidePadding = if (resultState.showLogo) 14.dp else 4.dp
- val tfFocusRequester = remember { FocusRequester() }
-
- TextField(
- placeholder = {
- Text(text = stringResource(R.string.sb_message)
- .format(stringResource(serpProvider.providerInfo!!.titleInSearchBox)))
- },
- value = resultModel.query,
- shape = RoundedCornerShape(60.dp),
- colors = TextFieldDefaults.colors(
- focusedIndicatorColor = Color.Transparent,
- unfocusedIndicatorColor = Color.Transparent,
- disabledIndicatorColor = Color.Transparent
- ),
- interactionSource = remember { MutableInteractionSource() }
- .also { interactionSource ->
- LaunchedEffect(interactionSource) {
- interactionSource.interactions.collect {
- // If the field is pressed, hide logo
- if (it is PressInteraction.Release)
- resultModel.hideLogo()
- }
- }
- },
- modifier = Modifier
- .padding(
- top = 4.dp, bottom = 4.dp,
- start = tfSidePadding, end = tfSidePadding
+ if (!bottomSearchField) {
+ SearchField(
+ resultModel,
+ tfSidePadding,
+ tfFocusRequester,
+ context,
+ resultState,
+ bottomSearchField
)
- .fillMaxWidth()
- .focusRequester(tfFocusRequester)
- .testTag(Global.TEST_TAG_RP_QUERY_FIELD),
- onValueChange = { nv ->
- resultModel.updateQuery(nv)
- },
- keyboardActions = KeyboardActions(
- onSearch = {
- if (resultModel.query == "_gugal_devopts") {
- val intent = Intent(context, DevOptMainActivity::class.java)
- intent.flags = FLAG_ACTIVITY_NEW_TASK
- context.startActivity(intent)
- } else {
- saveSearch(resultModel.query)
- // Clear error response
- error.value = null
- resultModel.search()
- }
+ }
+
+ // past search card
+ AnimatedVisibility(
+ resultState.showLogo,
+ enter = fadeIn(),
+ exit = fadeOut()
+ ) {
+ Box(modifier = Modifier.fillMaxWidth()) {
+ if (m3eMode)
+ ExSearchHistory(onClick = { query ->
+ resultModel.updateQuery(query)
+ resultModel.search()
+ }, fillOnClick = { query ->
+ resultModel.updateQuery(query)
+ // Focus on result field
+ tfFocusRequester.requestFocus()
+ }, demoMode)
+ else
+ SearchHistory(onClick = { query ->
+ resultModel.updateQuery(query)
+ resultModel.search()
+ }, fillOnClick = { query ->
+ resultModel.updateQuery(query)
+ // Focus on result field
+ tfFocusRequester.requestFocus()
+ }, demoMode)
}
- ),
- singleLine = true,
- keyboardOptions = KeyboardOptions(
- imeAction = ImeAction.Search
- ),
- trailingIcon = {
- if (!resultState.showLogo)
- IconButton(
- onClick = {
- resultModel.reset()
- }
- ) {
- Icon(
- painter = painterResource(R.drawable.ic_clear),
- contentDescription = stringResource(R.string.btn_clear)
- )
- }
}
- )
- // past search card
- AnimatedVisibility (resultState.showLogo,
- enter = fadeIn(),
- exit = fadeOut()) {
- Box(modifier = Modifier.fillMaxWidth()) {
- SearchHistory(onClick = { query ->
- resultModel.updateQuery(query)
- resultModel.search()
- }, fillOnClick = { query ->
- resultModel.updateQuery(query)
- // Focus on result field
- tfFocusRequester.requestFocus()
- })
+ // If a search query was passed, show it
+ if (searchQuery.isNotEmpty() && lastSearchQuery != searchQuery) {
+ lastSearchQuery = searchQuery
+ resultModel.updateQuery(searchQuery)
+ // Focus on result field
+ LaunchedEffect(composeDone, block = {
+ tfFocusRequester.requestFocus()
+ resultModel.hideLogo()
+ })
+ }
+ // Check if the search field should automatically be selected
+ if (autoSelectSearch) {
+ Log.d("gugal#rp", "auto selecting search field")
+ // Focus on result field
+ LaunchedEffect(composeDone, block = {
+ tfFocusRequester.requestFocus()
+ resultModel.hideLogo()
+ })
}
- }
- // If a search query was passed, show it
- if (searchQuery.isNotEmpty() && lastSearchQuery != searchQuery) {
- lastSearchQuery = searchQuery
- resultModel.updateQuery(searchQuery)
- // Focus on result field
- LaunchedEffect(composeDone, block = {
- tfFocusRequester.requestFocus()
- })
- }
+ // loader
+ AnimatedVisibility(resultState.showLoadingBar) {
+ LinearProgressIndicator(Modifier.fillMaxWidth())
+ }
- // loader
- AnimatedVisibility (resultState.showLoadingBar) {
- LinearProgressIndicator(Modifier.fillMaxWidth())
- }
+ // results list
+ if (resultState.error == null) {
+ AnimatedVisibility(
+ !resultState.showLoadingBar,
+ enter = fadeIn(),
+ exit = fadeOut()
+ ) {
+ Results(
+ results = resultState.results,
+ context = context,
+ sizeClass = resultState.sizeClass,
+ resultModel = resultModel,
+ // experiments
+ usePages = resultState.enablePages,
+ useGrid = resultState.enableGrid,
+ useCustomTabs = resultState.customTabs
+ )
+ }
+ }
- // results list
- if (resultState.error == null) {
- AnimatedVisibility (!resultState.showLoadingBar,
- enter = fadeIn(),
- exit = fadeOut()) {
- Results(
- results = resultState.results,
- context,
- resultState.sizeClass,
- resultModel
- )
+ // automatically handle some error responses
+ LaunchedEffect(resultState.error) {
+ if (resultState.error != null) {
+ if ((resultState.error?.errorCode
+ ?: "none") == ErrorResponse.ERROR_CODE_INVALID_CREDENTIALS
+ ) {
+ val intent = Intent(context, SetupConfigureSerpActivity::class.java)
+ intent.putExtra("launchedFromSearchError", true)
+ context.startActivity(intent, null)
+ }
+ }
}
- }
+
// error response
AnimatedVisibility (
resultState.error != null,
enter = fadeIn(),
exit = fadeOut()
) {
- if (resultState.error!!.errorCode == "_NRR") ErrorMessage("")
- else if (resultState.error!!.errorCode == "_RLR") ErrorMessage(stringResource(R.string.rp_error_rate_limited))
- else ErrorMessage(resultState.error!!.body)
+ // this is rendered with a null error response during the animation,
+ // so check if the error is null first
+ if (resultState.error != null) {
+ if (resultState.error!!.errorCode == "_NRR") SearchErrorMessage("")
+ else if (resultState.error!!.errorCode == "_RLR") SearchErrorMessage(stringResource(R.string.rp_error_rate_limited))
+ else SearchErrorMessage(resultState.error!!.body)
+ }
}
}
composeDone.value = true
}
+@Composable
+private fun SearchField(
+ resultModel: ResultViewModel,
+ tfSidePadding: Dp,
+ tfFocusRequester: FocusRequester,
+ context: Activity,
+ resultState: ResultState,
+ accountForBottom: Boolean
+) {
+ val paddingModifier = if (accountForBottom) {
+ Modifier.padding(
+ top = 16.dp, bottom = 16.dp,
+ start = tfSidePadding, end = tfSidePadding
+ )
+ } else {
+ Modifier.padding(
+ top = 4.dp, bottom = 4.dp,
+ start = tfSidePadding, end = tfSidePadding
+ )
+ }
+ TextField(
+ placeholder = {
+ Text(
+ text = stringResource(R.string.sb_message)
+ .format(stringResource(serpProvider.providerInfo!!.titleInSearchBox))
+ )
+ },
+ value = resultModel.query,
+ shape = RoundedCornerShape(60.dp),
+ colors = TextFieldDefaults.colors(
+ focusedIndicatorColor = Color.Transparent,
+ unfocusedIndicatorColor = Color.Transparent,
+ disabledIndicatorColor = Color.Transparent
+ ),
+ interactionSource = remember { MutableInteractionSource() }
+ .also { interactionSource ->
+ LaunchedEffect(interactionSource) {
+ interactionSource.interactions.collect {
+ // If the field is pressed, hide logo
+ if (it is PressInteraction.Release)
+ resultModel.hideLogo()
+ }
+ }
+ },
+ modifier = paddingModifier
+ .fillMaxWidth()
+ .focusRequester(tfFocusRequester)
+ .testTag(Global.TEST_TAG_RP_QUERY_FIELD),
+ onValueChange = { nv ->
+ resultModel.updateQuery(nv)
+ },
+ keyboardActions = KeyboardActions(
+ onSearch = {
+ if (resultModel.query == "_gugal_devopts") {
+ val intent = Intent(context, DevOptMainActivity::class.java)
+ intent.flags = FLAG_ACTIVITY_NEW_TASK
+ context.startActivity(intent)
+ } else {
+ saveSearch(resultModel.query)
+ resultModel.search()
+ }
+ }
+ ),
+ singleLine = true,
+ keyboardOptions = KeyboardOptions(
+ imeAction = ImeAction.Search
+ ),
+ trailingIcon = {
+ if (!resultState.showLogo)
+ IconButton(
+ onClick = {
+ resultModel.reset()
+ }
+ ) {
+ Icon(
+ painter = painterResource(R.drawable.ic_clear),
+ contentDescription = stringResource(R.string.btn_clear)
+ )
+ }
+ }
+ )
+}
+
@Composable
fun PageButtons(resultModel: ResultViewModel) {
val page = resultModel.getPage()
@@ -282,45 +372,23 @@ fun saveSearch(tsvt: String) {
}
}
-@Composable
-fun ErrorMessage(body: String) {
- Column(
- modifier = Modifier.fillMaxSize(),
- verticalArrangement = Arrangement.Center,
- horizontalAlignment = Alignment.CenterHorizontally
- ) {
- if (body.isNotEmpty()) {
- Text(
- stringResource(R.string.rp_error),
- textAlign = TextAlign.Center,
- style = MaterialTheme.typography.headlineSmall
- )
- Text(
- body,
- modifier = Modifier
- .padding(all = 10.dp)
- .fillMaxWidth(),
- textAlign = TextAlign.Center
- )
- } else {
- Text(
- stringResource(R.string.rp_noresult),
- textAlign = TextAlign.Center,
- style = MaterialTheme.typography.headlineSmall
- )
- }
- }
-}
-
@ExperimentalAnimationApi
@Composable
-fun SearchHistory(onClick: (String) -> Unit, fillOnClick: (String) -> Unit) {
- val searches: List = if (Global.demoMode) Global.demoModeQueries.toList() else visibleSearchHistory.toList()
+fun SearchHistory(
+ onClick: (String) -> Unit, fillOnClick: (String) -> Unit,
+ demoMode: Boolean = false
+) {
+ val searches: List = if (demoMode) DemoMode.queries.toList() else visibleSearchHistory.toList()
Surface(
shape = MaterialTheme.shapeScheme.large,
tonalElevation = cardElevation,
modifier = Modifier
- .padding(start = 14.dp, end = 14.dp, top = 24.dp, bottom = 24.dp)
+ .padding(
+ start = 16.dp,
+ end = 16.dp,
+ top = 16.dp,
+ bottom = 0.dp
+ )
.fillMaxWidth()
.fillMaxHeight()
) {
@@ -332,42 +400,26 @@ fun SearchHistory(onClick: (String) -> Unit, fillOnClick: (String) -> Unit) {
}
}
+@ExperimentalAnimationApi
@Composable
-fun PastSearch(query: String, onClick: () -> Unit, fillOnClick: () -> Unit) {
- val fillActionDesc = stringResource(R.string.action_edit_past_search)
- val searchActionDesc = stringResource(R.string.action_do_past_search)
- Row(
+fun ExSearchHistory(
+ onClick: (String) -> Unit, fillOnClick: (String) -> Unit,
+ demoMode: Boolean = false
+) {
+ val searches: List = if (demoMode) DemoMode.queries.toList() else visibleSearchHistory.toList()
+ LazyColumn(
modifier = Modifier
+ .padding(
+ start = 16.dp,
+ end = 16.dp,
+ top = 16.dp,
+ bottom = 0.dp
+ )
.fillMaxWidth()
- .clickable(onClick = onClick)
- .padding(top = 5.dp, bottom = 5.dp, start = 20.dp, end = 5.dp)
- .semantics {
- // Set any explicit semantic properties
- customActions = listOf(
- CustomAccessibilityAction(searchActionDesc) {
- onClick()
- /*return*/ true
- },
- CustomAccessibilityAction(fillActionDesc) {
- fillOnClick()
- /*return*/ true
- }
- )
- },
- verticalAlignment = Alignment.CenterVertically,
+ .fillMaxHeight()
) {
- Text(
- text = query,
- style = MaterialTheme.typography.titleLarge,
- maxLines = 1,
- overflow = TextOverflow.Ellipsis,
- modifier = Modifier.weight(2f)
- )
- IconButton(
- onClick = fillOnClick,
- modifier = Modifier.clearAndSetSemantics { }
- ) {
- Icon(painterResource(id = R.drawable.ic_fill_result), stringResource(R.string.btn_fill_result))
+ materialListItems(searches) { message ->
+ PastSearch(message, onClick = {onClick(message)}, fillOnClick = {fillOnClick(message)})
}
}
}
\ No newline at end of file
diff --git a/app/src/main/java/com/porg/gugal/ui/SearchSettingsActivity.kt b/app/src/main/java/com/porg/gugal/ui/SearchSettingsActivity.kt
index 116be839ce7135a62f80c3bd0a75cfd698e73144..126969b1880ad8a0439f5e31f312748287771919 100644
--- a/app/src/main/java/com/porg/gugal/ui/SearchSettingsActivity.kt
+++ b/app/src/main/java/com/porg/gugal/ui/SearchSettingsActivity.kt
@@ -1,7 +1,7 @@
/*
* SearchSettingsActivity.kt
* Gugal
- * Copyright (c) 2023 thegreatporg
+ * Copyright (c) 2023-2025 the Gugal maintainers
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
@@ -29,17 +29,31 @@ import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
-import androidx.compose.material.icons.filled.ArrowBack
-import androidx.compose.material3.*
+import androidx.compose.material.icons.automirrored.filled.ArrowBack
+import androidx.compose.material3.AlertDialog
+import androidx.compose.material3.ExperimentalMaterial3Api
+import androidx.compose.material3.Icon
+import androidx.compose.material3.IconButton
+import androidx.compose.material3.LargeTopAppBar
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Scaffold
+import androidx.compose.material3.Surface
+import androidx.compose.material3.Text
+import androidx.compose.material3.TextButton
+import androidx.compose.material3.TopAppBarDefaults
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.input.nestedscroll.nestedScroll
+import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextOverflow
import androidx.core.view.WindowCompat
+import com.porg.gugal.Global
import com.porg.gugal.R
import com.porg.gugal.setup.SetupConfigureSerpActivity
import com.porg.gugal.setup.SetupSelectSerpActivity
import com.porg.gugal.ui.theme.GugalTheme
-import com.porg.m3.settings.RegularSetting
+import tech.cataspect.m3x.settings.RegularSetting
class SearchSettingsActivity: ComponentActivity() {
@OptIn(
@@ -50,8 +64,38 @@ class SearchSettingsActivity: ComponentActivity() {
val that = this
WindowCompat.setDecorFitsSystemWindows(window, false)
setContent {
+ val openClearDialog = remember { mutableStateOf(false) }
val scrollBehavior = TopAppBarDefaults.exitUntilCollapsedScrollBehavior()
GugalTheme {
+ if (openClearDialog.value) {
+ AlertDialog(
+ onDismissRequest = {openClearDialog.value = false},
+ title = {Text(text = stringResource(R.string.dialog_confirm))},
+ text = {Text(text = stringResource(R.string.dialog_clearHistory))},
+ confirmButton = {
+ TextButton(
+ onClick = {
+ // Clear search history in prefs
+ with (Global.sharedPreferences.edit()) {
+ remove("pastSearches")
+ remove("visiblePastSearches")
+ apply()
+ }
+ // Clear local copy
+ Global.visibleSearchHistory = mutableListOf()
+ Global.fullSearchHistory = mutableListOf()
+ // Close dialog
+ openClearDialog.value = false
+ }
+ ) {Text(stringResource(R.string.btn_yes))}
+ },
+ dismissButton = {
+ TextButton(
+ onClick = {openClearDialog.value = false}
+ ) {Text(stringResource(R.string.btn_no))}
+ }
+ )
+ }
Scaffold(
modifier = Modifier.nestedScroll(scrollBehavior.nestedScrollConnection),
topBar = {
@@ -66,8 +110,8 @@ class SearchSettingsActivity: ComponentActivity() {
navigationIcon = {
IconButton(onClick = { finish() }) {
Icon(
- imageVector = Icons.Filled.ArrowBack,
- contentDescription = "Go back",
+ imageVector = Icons.AutoMirrored.Filled.ArrowBack,
+ contentDescription = stringResource(id = R.string.btn_back),
)
}
},
@@ -81,8 +125,9 @@ class SearchSettingsActivity: ComponentActivity() {
.verticalScroll(rememberScrollState())
.fillMaxSize()
) {
- RegularSetting(R.string.setting_cred_title,
- R.string.setting_cred_desc,
+ RegularSetting(
+ stringResource(R.string.setting_cred_title),
+ stringResource(R.string.setting_cred_desc),
onClick = {
val intent = Intent(that, SetupConfigureSerpActivity::class.java)
intent.flags = Intent.FLAG_ACTIVITY_NEW_TASK
@@ -90,14 +135,20 @@ class SearchSettingsActivity: ComponentActivity() {
startActivity(intent, null)
})
RegularSetting(
- R.string.setting_serp_title,
- R.string.setting_serp_desc,
+ stringResource(R.string.setting_serp_title),
+ stringResource(R.string.setting_serp_desc),
onClick = {
val intent = Intent(that, SetupSelectSerpActivity::class.java)
intent.flags = Intent.FLAG_ACTIVITY_NEW_TASK
intent.putExtra("launchedFromSettings", true)
startActivity(intent, null)
})
+ // Clear search history
+ if (Global.visibleSearchHistory.isNotEmpty())
+ RegularSetting(
+ stringResource(R.string.setting_clearHistory_title),
+ stringResource(R.string.setting_clearHistory_desc),
+ onClick = { openClearDialog.value = true })
}
}
}
diff --git a/app/src/main/java/com/porg/gugal/ui/SettingsPage.kt b/app/src/main/java/com/porg/gugal/ui/SettingsPage.kt
index cd48d4ef9a5228a9dc6b69e4ca69482ec5eaae81..e1e5cb16f806e05f46724564f663cf489fc13c88 100644
--- a/app/src/main/java/com/porg/gugal/ui/SettingsPage.kt
+++ b/app/src/main/java/com/porg/gugal/ui/SettingsPage.kt
@@ -1,7 +1,7 @@
/*
* SettingsPage.kt
* Gugal
- * Copyright (c) 2023 thegreatporg
+ * Copyright (c) 2023-2025 the Gugal maintainers
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
@@ -46,35 +46,39 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
-import androidx.core.content.ContextCompat.startActivity
import com.porg.gugal.BuildConfig
import com.porg.gugal.Global
import com.porg.gugal.Global.Companion.THEME_DARK
import com.porg.gugal.Global.Companion.THEME_LIGHT
import com.porg.gugal.Global.Companion.THEME_SYSTEM
-import com.porg.gugal.MainActivity
import com.porg.gugal.R
import com.porg.gugal.devopt.DevOptMainActivity
import com.porg.gugal.news.NewsSettingsActivity
-import com.porg.m3.settings.RegularPreviewSetting
-import com.porg.m3.settings.RegularSetting
+import tech.cataspect.m3x.settings.RegularPreviewSetting
+import tech.cataspect.m3x.settings.RegularSetting
+import tech.cataspect.m3x.settings.ToggleSetting
@Composable
-fun SettingsPage(context: Activity?) {
+fun SettingsPage(context: Activity?, enableBackup: Boolean) {
Column(
modifier = Modifier.verticalScroll(rememberScrollState())
) {
val openThemeDialog = remember { mutableStateOf(false) }
- val openClearDialog = remember { mutableStateOf(false) }
+
+ val bottomSearchField = remember {mutableStateOf(
+ Global.sharedPreferences.getBoolean("bottomSearchField", false)
+ )}
// Search settings
- RegularSetting(R.string.setting_search_title,
- R.string.setting_search_desc,
+ RegularSetting(
+ stringResource(R.string.setting_search_title),
+ stringResource(R.string.setting_search_desc),
onClick = {
val intent = Intent(context, SearchSettingsActivity::class.java)
intent.flags = Intent.FLAG_ACTIVITY_NEW_TASK
- startActivity(context!!, intent, null)
- })
+ context!!.startActivity(intent, null)
+ }
+ )
// News
RegularPreviewSetting(stringResource(R.string.setting_news_title),
@@ -82,23 +86,28 @@ fun SettingsPage(context: Activity?) {
onClick = {
val intent = Intent(context, NewsSettingsActivity::class.java)
intent.flags = Intent.FLAG_ACTIVITY_NEW_TASK
- startActivity(context!!, intent, null)
+ context!!.startActivity(intent, null)
}
)
- // Clear search history
- if (Global.visibleSearchHistory.isNotEmpty())
- RegularSetting(
- stringResource(R.string.setting_clearHistory_title),
- stringResource(R.string.setting_clearHistory_desc),
- onClick = { openClearDialog.value = true })
-
// Change theme
RegularSetting(
stringResource(R.string.setting_changeTheme_title),
stringResource(R.string.setting_changeTheme_desc),
onClick = { openThemeDialog.value = true })
+ // Backup and restore
+ if (enableBackup) {
+ RegularPreviewSetting(
+ stringResource(R.string.setting_backup_title),
+ stringResource(R.string.setting_backup_desc),
+ onClick = {
+ val intent = Intent(context, BackupSettingsActivity::class.java)
+ context!!.startActivity(intent, null)
+ }
+ )
+ }
+
// Dev options (debug only)
if (BuildConfig.VERSION_NAME.endsWith("-debug"))
RegularSetting(
@@ -107,48 +116,34 @@ fun SettingsPage(context: Activity?) {
onClick = {
val intent = Intent(context, DevOptMainActivity::class.java)
intent.flags = Intent.FLAG_ACTIVITY_NEW_TASK
- startActivity(context!!, intent, null)
+ context!!.startActivity(intent, null)
})
+ ToggleSetting(
+ stringResource(R.string.setting_bottom_search_title),
+ stringResource(R.string.setting_bottom_search_desc),
+ onCheckedChange = { checked ->
+ // update checked value
+ bottomSearchField.value = checked
+ // update shared preference
+ with (Global.sharedPreferences.edit()) {
+ putBoolean("bottomSearchField", checked)
+ apply()
+ }
+ },
+ selected = bottomSearchField.value
+ )
+
// About setting
RegularSetting(
- R.string.setting_about_title,
- R.string.setting_about_desc,
+ stringResource(R.string.setting_about_title),
+ stringResource(R.string.setting_about_desc),
onClick = {
val intent = Intent(context, AboutActivity::class.java)
intent.flags = Intent.FLAG_ACTIVITY_NEW_TASK
- startActivity(context!!, intent, null)
+ context!!.startActivity(intent, null)
})
- if (openClearDialog.value) {
- AlertDialog(
- onDismissRequest = {openClearDialog.value = false},
- title = {Text(text = stringResource(R.string.dialog_confirm))},
- text = {Text(text = stringResource(R.string.dialog_clearHistory))},
- confirmButton = {
- TextButton(
- onClick = {
- // Clear search history in prefs
- with (Global.sharedPreferences.edit()) {
- remove("pastSearches")
- remove("visiblePastSearches")
- apply()
- }
- // Clear local copy
- Global.visibleSearchHistory = mutableListOf()
- Global.fullSearchHistory = mutableListOf()
- // Close dialog
- openClearDialog.value = false
- }
- ) {Text(stringResource(R.string.btn_yes))}
- },
- dismissButton = {
- TextButton(
- onClick = {openClearDialog.value = false}
- ) {Text(stringResource(R.string.btn_no))}
- }
- )
- }
if (openThemeDialog.value) ThemeDialog(openThemeDialog, context!!)
Text(
@@ -170,29 +165,29 @@ fun ThemeDialog(state: MutableState, context: Activity) {
title = {Text(text = stringResource(R.string.setting_changeTheme_title))},
text = {Column {
// Android 8-9 have a hidden dark mode, which may be visible on some OEM skins
- // (e.g. Samsung One UI). It works with Gugal's dark mode detection.
- if (Build.VERSION.SDK_INT == Build.VERSION_CODES.O) {
+ // (like Samsung One UI). It works with Gugal's dark mode detection.
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
RadioListItem(
- title = stringResource(R.string.dialog_theme_system_oreo),
+ title = stringResource(R.string.dialog_theme_system),
selected = pref == THEME_SYSTEM,
- onClick = {pref = THEME_SYSTEM}
+ onClick = { pref = THEME_SYSTEM }
)
- } else if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
+ } else if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
RadioListItem(
- title = stringResource(R.string.dialog_theme_system),
+ title = stringResource(R.string.dialog_theme_system_oreo),
selected = pref == THEME_SYSTEM,
- onClick = {pref = THEME_SYSTEM}
+ onClick = { pref = THEME_SYSTEM }
)
}
RadioListItem(
title = stringResource(R.string.dialog_theme_dark),
selected = pref == THEME_DARK,
- onClick = {pref = THEME_DARK}
+ onClick = { pref = THEME_DARK }
)
RadioListItem(
title = stringResource(R.string.dialog_theme_light),
selected = pref == THEME_LIGHT,
- onClick = {pref = THEME_LIGHT}
+ onClick = { pref = THEME_LIGHT }
)
}},
confirmButton = {
diff --git a/app/src/main/java/com/porg/gugal/ShareActivity.kt b/app/src/main/java/com/porg/gugal/ui/ShareActivity.kt
similarity index 91%
rename from app/src/main/java/com/porg/gugal/ShareActivity.kt
rename to app/src/main/java/com/porg/gugal/ui/ShareActivity.kt
index 5cc26f29cb147d91f004a219366e68e189abdd02..bee7ed6c971ed5ef8f376cbbdbbc70b8ad0c4ecd 100644
--- a/app/src/main/java/com/porg/gugal/ShareActivity.kt
+++ b/app/src/main/java/com/porg/gugal/ui/ShareActivity.kt
@@ -1,7 +1,7 @@
/*
* ShareActivity.kt
* Gugal
- * Copyright (c) 2022 thegreatporg
+ * Copyright (c) 2024-2025 the Gugal maintainers
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
@@ -17,7 +17,7 @@
* along with this program. If not, see .
*/
-package com.porg.gugal
+package com.porg.gugal.ui
import android.content.Intent
import android.net.Uri
@@ -42,6 +42,12 @@ class ShareActivity: ComponentActivity() {
openGugal(it as String)
}
}
+ // web search
+ else if (intent?.action == Intent.ACTION_WEB_SEARCH) {
+ intent.getStringExtra("query")?.let {
+ openGugal(it)
+ }
+ }
// search engine links
else if (intent?.action == Intent.ACTION_VIEW) {
val uri: Uri? = intent?.data
diff --git a/app/src/main/java/com/porg/gugal/ui/components/GugalLogo.kt b/app/src/main/java/com/porg/gugal/ui/components/GugalLogo.kt
new file mode 100644
index 0000000000000000000000000000000000000000..d491cf51aa02458575b030fceaeb2973d33d4562
--- /dev/null
+++ b/app/src/main/java/com/porg/gugal/ui/components/GugalLogo.kt
@@ -0,0 +1,63 @@
+/*
+ * GugalLogo.kt
+ * Gugal
+ * Copyright © 2025 the Gugal maintainers
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ */
+
+package com.porg.gugal.ui.components
+
+import androidx.compose.foundation.Image
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.shape.CircleShape
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.graphics.ColorFilter
+import androidx.compose.ui.layout.ContentScale
+import androidx.compose.ui.res.painterResource
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.unit.Dp
+import androidx.compose.ui.unit.dp
+import com.porg.gugal.R
+
+/**
+ * A medium Gugal logo.
+ *
+ * @param modifier a modifier to be applied, optional.
+ */
+@Composable
+fun GugalLogo(modifier: Modifier = Modifier, size: Dp = 96.dp, padding: Dp = 48.dp) {
+ val bg = MaterialTheme.colorScheme.primary
+ val fg = MaterialTheme.colorScheme.onPrimary
+ androidx.compose.material.Card(
+ modifier = Modifier
+ .padding(all = padding)
+ .size(size)
+ .then(modifier),
+ shape = CircleShape,
+ backgroundColor = bg
+ ) {
+ Image(
+ painterResource(R.drawable.ic_launcher_foreground),
+ contentDescription = stringResource(R.string.desc_logo),
+ contentScale = ContentScale.Crop,
+ modifier = Modifier.fillMaxSize(),
+ colorFilter = ColorFilter.tint(color = fg)
+ )
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/com/porg/gugal/ui/components/PastSearch.kt b/app/src/main/java/com/porg/gugal/ui/components/PastSearch.kt
new file mode 100644
index 0000000000000000000000000000000000000000..60bd267eedc2ca6dd0bd317f1a40ebe07b8dc419
--- /dev/null
+++ b/app/src/main/java/com/porg/gugal/ui/components/PastSearch.kt
@@ -0,0 +1,81 @@
+/*
+ * PastSearch.kt
+ * Gugal
+ * Copyright © 2025-2025 the Gugal maintainers
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ */
+
+package com.porg.gugal.ui.components
+
+import androidx.compose.foundation.clickable
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.padding
+import androidx.compose.material3.Icon
+import androidx.compose.material3.IconButton
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.res.painterResource
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.semantics.CustomAccessibilityAction
+import androidx.compose.ui.semantics.clearAndSetSemantics
+import androidx.compose.ui.semantics.customActions
+import androidx.compose.ui.semantics.semantics
+import androidx.compose.ui.text.style.TextOverflow
+import androidx.compose.ui.unit.dp
+import com.porg.gugal.R
+
+@Composable
+fun PastSearch(query: String, onClick: () -> Unit, fillOnClick: () -> Unit) {
+ val fillActionDesc = stringResource(R.string.action_edit_past_search)
+ val searchActionDesc = stringResource(R.string.action_do_past_search)
+ Row(
+ modifier = Modifier
+ .fillMaxWidth()
+ .clickable(onClick = onClick)
+ .padding(top = 5.dp, bottom = 5.dp, start = 20.dp, end = 5.dp)
+ .semantics {
+ // Set any explicit semantic properties
+ customActions = listOf(
+ CustomAccessibilityAction(searchActionDesc) {
+ onClick()
+ /*return*/ true
+ },
+ CustomAccessibilityAction(fillActionDesc) {
+ fillOnClick()
+ /*return*/ true
+ }
+ )
+ },
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ Text(
+ text = query,
+ style = MaterialTheme.typography.titleLarge,
+ maxLines = 1,
+ overflow = TextOverflow.Ellipsis,
+ modifier = Modifier.weight(2f)
+ )
+ IconButton(
+ onClick = fillOnClick,
+ modifier = Modifier.clearAndSetSemantics { }
+ ) {
+ Icon(painterResource(id = R.drawable.ic_fill_result), stringResource(R.string.btn_fill_result))
+ }
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/com/porg/gugal/ui/components/ResultCard.kt b/app/src/main/java/com/porg/gugal/ui/components/ResultCard.kt
new file mode 100644
index 0000000000000000000000000000000000000000..9a0d3674971946b5984263fc9050e9c9234db2d7
--- /dev/null
+++ b/app/src/main/java/com/porg/gugal/ui/components/ResultCard.kt
@@ -0,0 +1,127 @@
+/*
+ * ResultCard.kt
+ * Gugal
+ * Copyright © 2025 the Gugal maintainers
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ */
+
+package com.porg.gugal.ui.components
+
+import android.content.Context
+import android.content.Intent
+import android.net.Uri
+import androidx.browser.customtabs.CustomTabsIntent
+import androidx.compose.foundation.clickable
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.padding
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Surface
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.tooling.preview.Preview
+import androidx.compose.ui.unit.dp
+import com.porg.gugal.data.results.Result
+import com.porg.gugal.ui.cardElevation
+import com.porg.gugal.ui.theme.GugalTheme
+import com.porg.gugal.ui.theme.shapeScheme
+
+
+/**
+ * A clickable card that shows a search result's title, URL and description.
+ *
+ * @param result the [Result] to show.
+ * @param context the context of the calling activity, optional (only required for clickable results).
+ */
+@Composable
+fun ResultCard(result: Result, context: Context?, modifier: Modifier = Modifier, customTab: Boolean = false) {
+ Surface(
+ shape = MaterialTheme.shapeScheme.medium,
+ tonalElevation = cardElevation,
+ modifier = Modifier
+ .padding(all = 4.dp)
+ .fillMaxWidth()
+ .clickable(onClick = {
+ if (result.url != "") openBrowser(context!!, result.url, customTab)
+ })
+ .then(modifier),
+ ) {
+ Column(modifier = Modifier.padding(all = 8.dp)) {
+ if (result.domain != "") {
+ Text(
+ text = result.domain,
+ modifier = Modifier.padding(all = 2.dp),
+ style = MaterialTheme.typography.titleSmall
+ )
+ }
+ Text(
+ text = result.title,
+ modifier = Modifier.padding(all = 2.dp),
+ style = MaterialTheme.typography.titleLarge
+ )
+
+ if (result.bodyAnnotated != null) {
+ Text(
+ text = result.bodyAnnotated,
+ modifier = Modifier.padding(all = 2.dp),
+ style = MaterialTheme.typography.bodyLarge
+ )
+ }
+ else if (result.body != null) {
+ Text(
+ text = result.body,
+ modifier = Modifier.padding(all = 2.dp),
+ style = MaterialTheme.typography.bodyLarge
+ )
+ }
+ }
+ }
+}
+
+@Composable
+@Preview
+fun PreviewResultCard() {
+ GugalTheme {
+ ResultCard(
+ result = Result(
+ "Gugal",
+ "A clean, lightweight, FOSS web search app.",
+ "https://gitlab.com/narektor/gugal",
+ "gitlab.com"),
+ context = null
+ )
+ }
+}
+
+// TODO potentially: remove "Open in custom tab" experiment and replace with open() from Global.kt
+private fun openBrowser(context: Context, url: String, customTab: Boolean = false) {
+ if (customTab) {
+ val builder = CustomTabsIntent.Builder()
+ // show website title
+ builder.setShowTitle(true)
+ // animation for enter and exit of tab
+ builder.setStartAnimations(context, android.R.anim.fade_in, android.R.anim.fade_out)
+ builder.setExitAnimations(context, android.R.anim.fade_in, android.R.anim.fade_out)
+ // launch the passed URL
+ builder.build().launchUrl(context, Uri.parse(url))
+ } else {
+ val intent = Intent(Intent.ACTION_VIEW, Uri.parse(url))
+ context.startActivity(
+ Intent.createChooser(intent, "Open result in"),
+ null
+ )
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/com/porg/gugal/ui/components/Results.kt b/app/src/main/java/com/porg/gugal/ui/components/Results.kt
new file mode 100644
index 0000000000000000000000000000000000000000..8d4774d736e5c6e5f9c65fe8d187cc80500ba707
--- /dev/null
+++ b/app/src/main/java/com/porg/gugal/ui/components/Results.kt
@@ -0,0 +1,113 @@
+/*
+ * Results.kt
+ * Gugal
+ * Copyright © 2025 the Gugal maintainers
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ */
+
+package com.porg.gugal.ui.components
+
+import android.content.Context
+import androidx.compose.animation.ExperimentalAnimationApi
+import androidx.compose.foundation.layout.heightIn
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.lazy.LazyColumn
+import androidx.compose.foundation.lazy.grid.GridCells
+import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
+import androidx.compose.foundation.lazy.grid.items
+import androidx.compose.foundation.lazy.items
+import androidx.compose.material3.windowsizeclass.WindowWidthSizeClass
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.tooling.preview.Preview
+import androidx.compose.ui.unit.dp
+import com.porg.gugal.data.results.Result
+import com.porg.gugal.ui.ResultPage
+import com.porg.gugal.ui.PageButtons
+import com.porg.gugal.ui.theme.GugalTheme
+import com.porg.gugal.viewmodel.ResultViewModel
+
+/**
+ * A list of ResultCards.
+ *
+ * **Note:** this class is not intended for use in SERP providers.
+ *
+ * @param results the [Result]s to show.
+ * @param context the context of the calling activity, optional (only required for clickable results).
+ * @param sizeClass the current device's size class.
+ * @param usePages should search pages be used? (only works if this is a part of a [ResultPage]).
+ */
+@ExperimentalAnimationApi
+@Composable
+fun Results(
+ results: List,
+ context: Context?,
+ sizeClass: WindowWidthSizeClass,
+ resultModel: ResultViewModel? = null,
+ usePages: Boolean = false,
+ useGrid: Boolean = false,
+ useCustomTabs: Boolean = false
+) {
+ if (sizeClass == WindowWidthSizeClass.Compact)
+ LazyColumn {
+ items(results) { message ->
+ ResultCard(message, context, customTab = useCustomTabs)
+ }
+ if (usePages)
+ item { PageButtons(resultModel!!) }
+ }
+ else {
+ if (useGrid)
+ LazyVerticalGrid(
+ columns = GridCells.Adaptive(minSize = 384.dp)
+ ) {
+ items(results) { message ->
+ ResultCard(message, context, Modifier.heightIn(min = 40.dp), customTab = useCustomTabs)
+ }
+ if (usePages)
+ item { PageButtons(resultModel!!) }
+ }
+ else
+ LazyColumn(
+ Modifier.padding(horizontal = 96.dp)
+ ) {
+ items(results) { message ->
+ ResultCard(message, context, customTab = useCustomTabs)
+ }
+ if (usePages)
+ item { PageButtons(resultModel!!) }
+ }
+ }
+}
+
+@ExperimentalAnimationApi
+@Composable
+@Preview
+fun ResultsPreview() {
+ GugalTheme {
+ Results(
+ results = List(size = 3) {
+ Result(
+ "Google privacy scandal",
+ "Google have been fined for antitrust once again, a few days after people bought more than 5 Pixels.",
+ "about:blank",
+ "test.com"
+ )
+ },
+ null,
+ WindowWidthSizeClass.Compact
+ )
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/com/porg/gugal/ui/components/SearchErrorMessage.kt b/app/src/main/java/com/porg/gugal/ui/components/SearchErrorMessage.kt
new file mode 100644
index 0000000000000000000000000000000000000000..91f216465958022dd2b29cdc1a556d4335093026
--- /dev/null
+++ b/app/src/main/java/com/porg/gugal/ui/components/SearchErrorMessage.kt
@@ -0,0 +1,90 @@
+/*
+ * SearchErrorMessage.kt
+ * Gugal
+ * Copyright © 2025-2025 the Gugal maintainers
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ */
+
+package com.porg.gugal.ui.components
+
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.padding
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.text.style.TextAlign
+import androidx.compose.ui.unit.dp
+import com.porg.gugal.R
+import com.porg.gugal.providers.responses.ErrorResponse
+
+/**
+ * A full screen error message.
+ *
+ * **Note:** this class is not intended for use in SERP providers.
+ */
+@Composable
+fun SearchErrorMessage(body: String, code: String? = null) {
+ val content =
+ // Check if an error code was provided
+ if (code != null) {
+ when (code) {
+ // If it's the invalid credential code, ask the user to set up search again
+ ErrorResponse.ERROR_CODE_INVALID_CREDENTIALS -> {
+ stringResource(R.string.rp_error_invalid_creds)
+ }
+ // If it's the rate limit code, ask the user to wait
+ ErrorResponse.ERROR_CODE_RATE_LIMITED -> {
+ stringResource(R.string.rp_error_rate_limited)
+ }
+ // Otherwise use the provided body
+ else -> body
+ }
+ }
+ // Otherwise use the provided body
+ else body
+
+ Column(
+ modifier = Modifier.fillMaxSize(),
+ verticalArrangement = Arrangement.Center,
+ horizontalAlignment = Alignment.CenterHorizontally
+ ) {
+ if (body.isNotEmpty()) {
+ Text(
+ stringResource(R.string.rp_error),
+ textAlign = TextAlign.Center,
+ style = MaterialTheme.typography.headlineSmall
+ )
+ Text(
+ content,
+ modifier = Modifier
+ .padding(all = 10.dp)
+ .fillMaxWidth(),
+ textAlign = TextAlign.Center
+ )
+ } else {
+ Text(
+ stringResource(R.string.rp_noresult),
+ textAlign = TextAlign.Center,
+ style = MaterialTheme.typography.headlineSmall
+ )
+ }
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/com/porg/gugal/ui/expressive/ExSettingsPage.kt b/app/src/main/java/com/porg/gugal/ui/expressive/ExSettingsPage.kt
new file mode 100644
index 0000000000000000000000000000000000000000..0272609a6504b4ed56de8aafb46bad88947fc259
--- /dev/null
+++ b/app/src/main/java/com/porg/gugal/ui/expressive/ExSettingsPage.kt
@@ -0,0 +1,206 @@
+/*
+ * ExSettingsPage.kt
+ * Gugal
+ * Copyright © 2025 the Gugal maintainers
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ */
+
+package com.porg.gugal.ui.expressive
+
+import android.app.Activity
+import android.content.Intent
+import android.os.Build
+import android.widget.Toast
+import androidx.compose.animation.ExperimentalAnimationApi
+import androidx.compose.foundation.clickable
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.lazy.LazyColumn
+import androidx.compose.foundation.rememberScrollState
+import androidx.compose.foundation.verticalScroll
+import androidx.compose.material.ExperimentalMaterialApi
+import androidx.compose.material3.AlertDialog
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.RadioButton
+import androidx.compose.material3.Text
+import androidx.compose.material3.TextButton
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.MutableState
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.unit.dp
+import androidx.core.content.ContextCompat.getString
+import com.porg.gugal.BuildConfig
+import com.porg.gugal.Global
+import com.porg.gugal.Global.Companion.THEME_DARK
+import com.porg.gugal.Global.Companion.THEME_LIGHT
+import com.porg.gugal.Global.Companion.THEME_SYSTEM
+import com.porg.gugal.R
+import com.porg.gugal.devopt.DevOptMainActivity
+import com.porg.gugal.news.NewsSettingsActivity
+import com.porg.gugal.setup.SetupConfigureSerpActivity
+import com.porg.gugal.setup.SetupSelectSerpActivity
+import com.porg.gugal.ui.AboutActivity
+import com.porg.gugal.ui.BackupSettingsActivity
+import com.porg.gugal.ui.MainActivity
+import com.porg.gugal.ui.SearchSettingsActivity
+import tech.cataspect.m3x.MaterialList
+import tech.cataspect.m3x.materialListItems
+import tech.cataspect.m3x.settings.RegularPreviewSetting
+import tech.cataspect.m3x.settings.RegularSetting
+import tech.cataspect.m3x.settings.SettingsTitle
+import tech.cataspect.m3x.settings.ToggleSetting
+
+@Composable
+fun ExSettingsPage(context: Activity?, enableBackup: Boolean) {
+ val openThemeDialog = remember { mutableStateOf(false) }
+
+ val bottomSearchField = remember {mutableStateOf(
+ Global.sharedPreferences.getBoolean("bottomSearchField", false)
+ )}
+ LazyColumn(
+ Modifier.padding(16.dp, 0.dp)
+ ) {
+
+ item {
+ RegularSetting(
+ stringResource(R.string.setting_devOpt_title),
+ stringResource(R.string.setting_devOpt_desc),
+ onClick = {
+ val intent = Intent(context, DevOptMainActivity::class.java)
+ intent.flags = Intent.FLAG_ACTIVITY_NEW_TASK
+ context!!.startActivity(intent, null)
+ })
+ }
+
+ item {
+ // Search settings
+ SettingsTitle(stringResource(R.string.setting_search_title))
+ }
+ materialListItems(items = listOf(
+ getString(context!!, R.string.setting_cred_title),
+ getString(context, R.string.setting_serp_title),
+ getString(context, R.string.setting_clearHistory_title)
+ )) { set ->
+ RegularSetting(
+ set,
+ "Description",
+ onClick = {
+ Toast.makeText(context, "click", Toast.LENGTH_SHORT).show()
+ })
+ }
+
+
+ item {
+ // Search settings
+ SettingsTitle(stringResource(R.string.setting_news_title))
+ }
+ materialListItems(items = listOf(
+ getString(context, R.string.setting_news_toggle),
+ getString(context, R.string.setting_newsFeeds_title),
+ getString(context, R.string.setting_issue_title)
+ )) { set ->
+ RegularSetting(
+ set,
+ "Description",
+ onClick = {
+ Toast.makeText(context, "click", Toast.LENGTH_SHORT).show()
+ })
+ }
+
+ }
+}
+
+@OptIn(ExperimentalAnimationApi::class, ExperimentalMaterialApi::class)
+@Composable
+fun ThemeDialog(state: MutableState, context: Activity) {
+ var pref by remember {
+ mutableStateOf(Global.sharedPreferences.getInt("themeOverride", THEME_SYSTEM))
+ }
+ AlertDialog(
+ onDismissRequest = {state.value = false},
+ title = {Text(text = stringResource(R.string.setting_changeTheme_title))},
+ text = {Column {
+ // Android 8-9 have a hidden dark mode, which may be visible on some OEM skins
+ // (like Samsung One UI). It works with Gugal's dark mode detection.
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
+ RadioListItem(
+ title = stringResource(R.string.dialog_theme_system),
+ selected = pref == THEME_SYSTEM,
+ onClick = {pref = THEME_SYSTEM}
+ )
+ } else if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
+ RadioListItem(
+ title = stringResource(R.string.dialog_theme_system_oreo),
+ selected = pref == THEME_SYSTEM,
+ onClick = {pref = THEME_SYSTEM}
+ )
+ }
+ RadioListItem(
+ title = stringResource(R.string.dialog_theme_dark),
+ selected = pref == THEME_DARK,
+ onClick = {pref = THEME_DARK}
+ )
+ RadioListItem(
+ title = stringResource(R.string.dialog_theme_light),
+ selected = pref == THEME_LIGHT,
+ onClick = {pref = THEME_LIGHT}
+ )
+ }},
+ confirmButton = {
+ TextButton(
+ onClick = {
+ with (Global.sharedPreferences.edit()) {
+ this.putInt("themeOverride", pref)
+ apply()
+ }
+ // Close dialog
+ state.value = false
+ // Restart the app
+ val intent = Intent(context, MainActivity::class.java)
+ context.startActivity(intent)
+ context.finishAffinity()
+ }
+ ) {Text(stringResource(android.R.string.ok))}
+ }
+ )
+}
+
+@Composable
+fun RadioListItem(title: String, onClick: (() -> Unit), selected: Boolean) {
+ Row(
+ modifier = Modifier
+ .clickable { onClick() }
+ .fillMaxWidth()
+ .padding(all = 0.dp),
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ RadioButton(
+ selected = selected,
+ onClick = onClick
+ )
+ Text(
+ text = title,
+ style = MaterialTheme.typography.bodyMedium
+ )
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/com/porg/gugal/BarItem.kt b/app/src/main/java/com/porg/gugal/ui/nav/BarItem.kt
similarity index 90%
rename from app/src/main/java/com/porg/gugal/BarItem.kt
rename to app/src/main/java/com/porg/gugal/ui/nav/BarItem.kt
index 305d9e2eece6b0701313f40ed5c104141fc88678..c440f779ba051b85c138ae18a0ffd10a0e775eb7 100644
--- a/app/src/main/java/com/porg/gugal/BarItem.kt
+++ b/app/src/main/java/com/porg/gugal/ui/nav/BarItem.kt
@@ -1,26 +1,26 @@
-/*
- * BarItem.kt
- * Gugal
- * Copyright (c) 2022 thegreatporg
- *
- * This program is free software: you can redistribute it and/or modify
- * it under the terms of the GNU General Public License as published by
- * the Free Software Foundation, either version 3 of the License, or
- * (at your option) any later version.
- *
- * This program is distributed in the hope that it will be useful,
- * but WITHOUT ANY WARRANTY; without even the implied warranty of
- * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- * GNU General Public License for more details.
- *
- * You should have received a copy of the GNU General Public License
- * along with this program. If not, see .
- */
-
-package com.porg.gugal
-
-data class BarItem(
- val title: Int,
- val image: Int,
- val route: String
+/*
+ * BarItem.kt
+ * Gugal
+ * Copyright (c) 2022-2025 the Gugal maintainers
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ */
+
+package com.porg.gugal.ui.nav
+
+data class BarItem(
+ val title: Int,
+ val image: Int,
+ val route: String
)
\ No newline at end of file
diff --git a/app/src/main/java/com/porg/gugal/NavBarItems.kt b/app/src/main/java/com/porg/gugal/ui/nav/NavBarItems.kt
similarity index 72%
rename from app/src/main/java/com/porg/gugal/NavBarItems.kt
rename to app/src/main/java/com/porg/gugal/ui/nav/NavBarItems.kt
index 060f91aac4af810dcc445e41c8be7fd96e439775..08ed4a03e41a58f04a18b2425d37b0cb6f8aa514 100644
--- a/app/src/main/java/com/porg/gugal/NavBarItems.kt
+++ b/app/src/main/java/com/porg/gugal/ui/nav/NavBarItems.kt
@@ -1,52 +1,71 @@
-/*
- * NavBarItems.kt
- * Gugal
- * Copyright (c) 2022 thegreatporg
- *
- * This program is free software: you can redistribute it and/or modify
- * it under the terms of the GNU General Public License as published by
- * the Free Software Foundation, either version 3 of the License, or
- * (at your option) any later version.
- *
- * This program is distributed in the hope that it will be useful,
- * but WITHOUT ANY WARRANTY; without even the implied warranty of
- * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- * GNU General Public License for more details.
- *
- * You should have received a copy of the GNU General Public License
- * along with this program. If not, see .
- */
-
-package com.porg.gugal
-
-object NavBarItems {
- val BarItems = listOf(
- BarItem(
- title = R.string.nav_search,
- image = R.drawable.ic_search,
- route = "search"
- ),
- BarItem(
- title = R.string.nav_settings,
- image = R.drawable.ic_settings,
- route = "settings"
- ),
- )
- val BarItemsWithNews = listOf(
- BarItem(
- title = R.string.nav_search,
- image = R.drawable.ic_search,
- route = "search"
- ),
- BarItem(
- title = R.string.nav_news,
- image = R.drawable.ic_news,
- route = "news"
- ),
- BarItem(
- title = R.string.nav_settings,
- image = R.drawable.ic_settings,
- route = "settings"
- )
- )
+/*
+ * NavBarItems.kt
+ * Gugal
+ * Copyright (c) 2024-2025 the Gugal maintainers
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ */
+
+package com.porg.gugal.ui.nav
+
+import com.porg.gugal.R
+
+object NavBarItems {
+ val BarItems = listOf(
+ BarItem(
+ title = R.string.nav_search,
+ image = R.drawable.ic_search,
+ route = "search"
+ ),
+ BarItem(
+ title = R.string.nav_settings,
+ image = R.drawable.ic_settings,
+ route = "settings"
+ ),
+ )
+ val BarItemsWithNews = listOf(
+ BarItem(
+ title = R.string.nav_news,
+ image = R.drawable.ic_news,
+ route = "news"
+ ),
+ BarItem(
+ title = R.string.nav_search,
+ image = R.drawable.ic_search,
+ route = "search"
+ ),
+ BarItem(
+ title = R.string.nav_settings,
+ image = R.drawable.ic_settings,
+ route = "settings"
+ )
+ )
+ val BarItemsExpressive = listOf(
+ BarItem(
+ title = R.string.nav_news,
+ image = R.drawable.ic_news,
+ route = "news"
+ ),
+ BarItem(
+ title = R.string.nav_search,
+ image = R.drawable.ic_search,
+ route = "search"
+ ),
+ BarItem(
+ title = R.string.nav_settings,
+ image = R.drawable.ic_settings,
+ route = "ex_settings"
+ )
+ )
}
\ No newline at end of file
diff --git a/app/src/main/java/com/porg/gugal/Routes.kt b/app/src/main/java/com/porg/gugal/ui/nav/Routes.kt
similarity index 87%
rename from app/src/main/java/com/porg/gugal/Routes.kt
rename to app/src/main/java/com/porg/gugal/ui/nav/Routes.kt
index ff8a879b044769c1fad48dd90983e8ed9b9f8b74..7f83758796ffcf46a77e9bb7ddd1d3d50b0eb983 100644
--- a/app/src/main/java/com/porg/gugal/Routes.kt
+++ b/app/src/main/java/com/porg/gugal/ui/nav/Routes.kt
@@ -1,26 +1,27 @@
-/*
- * Routes.kt
- * Gugal
- * Copyright (c) 2022 thegreatporg
- *
- * This program is free software: you can redistribute it and/or modify
- * it under the terms of the GNU General Public License as published by
- * the Free Software Foundation, either version 3 of the License, or
- * (at your option) any later version.
- *
- * This program is distributed in the hope that it will be useful,
- * but WITHOUT ANY WARRANTY; without even the implied warranty of
- * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- * GNU General Public License for more details.
- *
- * You should have received a copy of the GNU General Public License
- * along with this program. If not, see .
- */
-
-package com.porg.gugal
-
-sealed class Routes(val route: String) {
- object Search : Routes("search")
- object Settings : Routes("settings")
- object News : Routes("news")
+/*
+ * Routes.kt
+ * Gugal
+ * Copyright (c) 2024-2025 the Gugal maintainers
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ */
+
+package com.porg.gugal.ui.nav
+
+sealed class Routes(val route: String) {
+ object Search : Routes("search")
+ object Settings : Routes("settings")
+ object News : Routes("news")
+ object ExSettings : Routes("ex_settings")
}
\ No newline at end of file
diff --git a/app/src/main/java/com/porg/gugal/viewmodel/BackupCreateState.kt b/app/src/main/java/com/porg/gugal/viewmodel/BackupCreateState.kt
new file mode 100644
index 0000000000000000000000000000000000000000..18a511559e3bce737d40cfa934d3ac83aed28e9f
--- /dev/null
+++ b/app/src/main/java/com/porg/gugal/viewmodel/BackupCreateState.kt
@@ -0,0 +1,38 @@
+/*
+ * BackupCreateState.kt
+ * Gugal
+ * Copyright © 2025 the Gugal maintainers
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ */
+
+package com.porg.gugal.viewmodel
+
+data class BackupCreateState(
+ val encrypt: Boolean = false,
+ val showSuccess: Boolean = false,
+ val error: BackupCreateError? = null,
+ val password: String? = null
+)
+
+enum class BackupCreateError {
+ /**
+ * Only for locked backups: the password doesn't meet security requirements.
+ */
+ BAD_PASSWORD,
+ /**
+ * Only for locked backups: no password is specified.
+ */
+ NO_PASSWORD
+}
\ No newline at end of file
diff --git a/app/src/main/java/com/porg/gugal/viewmodel/BackupCreateViewModel.kt b/app/src/main/java/com/porg/gugal/viewmodel/BackupCreateViewModel.kt
new file mode 100644
index 0000000000000000000000000000000000000000..4055bc0e34ccc8f526e38566e1dbc6ee1033c68a
--- /dev/null
+++ b/app/src/main/java/com/porg/gugal/viewmodel/BackupCreateViewModel.kt
@@ -0,0 +1,177 @@
+/*
+ * BackupCreateViewModel.kt
+ * Gugal
+ * Copyright (c) 2024-2025 the Gugal maintainers
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ */
+
+package com.porg.gugal.viewmodel
+
+import android.util.Base64
+import androidx.lifecycle.ViewModel
+import androidx.lifecycle.viewModelScope
+import com.google.gson.Gson
+import com.porg.gugal.BuildConfig
+import com.porg.gugal.Global.Companion.BACKUP_VERSION
+import com.porg.gugal.Global.Companion.sharedPreferences
+import com.porg.gugal.common.AESCrypt
+import com.porg.gugal.common.KeyDerivation
+import com.porg.gugal.common.PasswordCheck
+import com.porg.gugal.common.PasswordScore
+import com.porg.gugal.data.preferences.PreferenceBackup
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.flow.asStateFlow
+import kotlinx.coroutines.flow.update
+import kotlinx.coroutines.launch
+import kotlinx.coroutines.withContext
+import java.io.FileOutputStream
+
+class BackupCreateViewModel: ViewModel() {
+ private val _uiState = MutableStateFlow(BackupCreateState())
+ val uiState: StateFlow = _uiState.asStateFlow()
+
+ fun setEncrypt(value: Boolean) {
+ _uiState.update { current ->
+ current.copy(
+ encrypt = value
+ )
+ }
+ }
+
+ fun setPassword(value: String) {
+ _uiState.update { current ->
+ current.copy(
+ password = value
+ )
+ }
+ }
+
+ private fun flattenSharedPrefs(): Map {
+ val flattenedPrefs = mutableMapOf()
+ for (pref in sharedPreferences.all) {
+ flattenedPrefs[pref.key] = pref.value
+ }
+ return flattenedPrefs.toMap()
+ }
+
+ private suspend fun getBackupObject(): PreferenceBackup = withContext(Dispatchers.Default) {
+ val pw = uiState.value.password
+ if (uiState.value.encrypt) {
+ if (pw != null) {
+ // get a simplified preference map
+ val prefMap = withContext(Dispatchers.Main) {flattenSharedPrefs()}
+ // convert it to JSON
+ val gson = Gson()
+ val blob = gson.toJson(prefMap)
+ // derive a key from the password
+ val salt =
+ Base64.encodeToString(KeyDerivation.generateRandomSalt(), Base64.NO_WRAP)
+ val key = KeyDerivation.generateHash(pw, salt)
+ // use that key to encrypt the blob
+ val aes = AESCrypt(key)
+ val encBlob = aes.encrypt(blob)
+ // assemble the final body
+ val prefs = "${aes.ALGORITHM},$salt,${encBlob.result},${encBlob.iv}"
+ // assemble the preference backup object
+ PreferenceBackup(
+ BACKUP_VERSION,
+ BuildConfig.VERSION_NAME,
+ encryptedPreferences = prefs
+ )
+ } else {
+ throw NullPointerException("Encrypted object requested but password is null")
+ }
+ } else {
+ // return a non-encrypted object
+ PreferenceBackup(
+ BACKUP_VERSION,
+ BuildConfig.VERSION_NAME,
+ preferences = withContext(Dispatchers.Main) {flattenSharedPrefs()}
+ )
+ }
+ }
+
+ private suspend fun startBackupAsync(stream: FileOutputStream) = withContext(Dispatchers.IO) {
+ _uiState.update {
+ it.copy(
+ showSuccess = false
+ )
+ }
+ // generate a backup object
+ try {
+ val backup = getBackupObject()
+ // convert to JSON
+ val gson = Gson()
+ val json = gson.toJson(backup)
+ // write the JSON to the stream
+ stream.write(json.toByteArray())
+ _uiState.update {
+ it.copy(
+ showSuccess = true
+ )
+ }
+ } catch (e: SecurityException) {
+ if (e.message == "password is too weak") {
+ _uiState.update {
+ it.copy(
+ showSuccess = false,
+ error = BackupCreateError.BAD_PASSWORD
+ )
+ }
+ } else {
+ throw e
+ }
+ } catch (e: NullPointerException) {
+ if (e.message == "Encrypted object requested but password is null") {
+ _uiState.update {
+ it.copy(
+ showSuccess = false,
+ error = BackupCreateError.NO_PASSWORD
+ )
+ }
+ } else {
+ throw e
+ }
+ }
+ }
+
+ fun startBackup(stream: FileOutputStream) {
+ viewModelScope.launch { startBackupAsync(stream) }
+ }
+
+ fun successShown() {
+ _uiState.update {
+ it.copy(
+ showSuccess = false
+ )
+ }
+ }
+
+ fun checkPassword(): BackupCreateError? {
+ val pw = uiState.value.password
+ // return null if the backup isn't encrypted
+ if (!uiState.value.encrypt) return null
+ // fail if there is no password
+ if (pw == null) return BackupCreateError.NO_PASSWORD
+ // check if the password is good enough
+ if (PasswordCheck.gradeAsScore(pw) < PasswordScore.MEDIUM) {
+ return BackupCreateError.BAD_PASSWORD
+ }
+ // otherwise return null, the pw is ok
+ return null
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/com/porg/gugal/viewmodel/BackupLoadState.kt b/app/src/main/java/com/porg/gugal/viewmodel/BackupLoadState.kt
new file mode 100644
index 0000000000000000000000000000000000000000..8a35626c05eac02e186ad13818badb7e93701bfa
--- /dev/null
+++ b/app/src/main/java/com/porg/gugal/viewmodel/BackupLoadState.kt
@@ -0,0 +1,34 @@
+/*
+ * BackupLoadState.kt
+ * Gugal
+ * Copyright © 2025 the Gugal maintainers
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ */
+
+package com.porg.gugal.viewmodel
+
+data class BackupLoadState(
+ val showSuccess: Boolean = false,
+ val showPasswordDialog: Boolean = false,
+ val error: BackupLoadError? = null,
+ val password: String? = null,
+ val json: String? = null
+)
+
+enum class BackupLoadError {
+ WRONG_PASSWORD,
+ BAD_FILE,
+ UNKNOWN_SERP_PROVIDER
+}
\ No newline at end of file
diff --git a/app/src/main/java/com/porg/gugal/viewmodel/BackupLoadViewModel.kt b/app/src/main/java/com/porg/gugal/viewmodel/BackupLoadViewModel.kt
new file mode 100644
index 0000000000000000000000000000000000000000..f959059413a7e5d2fbf2414bc4e9ad54ff47442d
--- /dev/null
+++ b/app/src/main/java/com/porg/gugal/viewmodel/BackupLoadViewModel.kt
@@ -0,0 +1,243 @@
+/*
+ * BackupLoadViewModel.kt
+ * Gugal
+ * Copyright © 2025 the Gugal maintainers
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ */
+
+package com.porg.gugal.viewmodel
+
+import androidx.lifecycle.ViewModel
+import androidx.lifecycle.viewModelScope
+import com.google.gson.Gson
+import com.porg.gugal.Global
+import com.porg.gugal.common.AESCrypt
+import com.porg.gugal.common.KeyDerivation
+import com.porg.gugal.data.preferences.PreferenceBackup
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.flow.asStateFlow
+import kotlinx.coroutines.flow.update
+import kotlinx.coroutines.launch
+import kotlinx.coroutines.withContext
+import java.io.FileInputStream
+import kotlin.reflect.jvm.jvmName
+import androidx.core.content.edit
+
+class BackupLoadViewModel: ViewModel() {
+ private val _uiState = MutableStateFlow(BackupLoadState())
+ val uiState: StateFlow = _uiState.asStateFlow()
+
+ fun setPassword(value: String) {
+ _uiState.update { current ->
+ current.copy(
+ password = value
+ )
+ }
+ }
+
+ private suspend fun loadPreferencesFromMap(map: Map) = withContext(Dispatchers.IO) {
+ Global.sharedPreferences.edit {
+ for (kv in map) {
+ when (kv.value) {
+ is Int -> {
+ putInt(kv.key, kv.value as Int)
+ }
+
+ is String -> {
+ putString(kv.key, kv.value as String)
+ }
+
+ is Boolean -> {
+ putBoolean(kv.key, kv.value as Boolean)
+ }
+
+ is Long -> {
+ putLong(kv.key, kv.value as Long)
+ }
+
+ is Float -> {
+ putFloat(kv.key, kv.value as Float)
+ }
+
+ is Double -> {
+ // convert doubles to floats as sharedprefs don't natively support them
+ putFloat(kv.key, (kv.value as Double).toFloat())
+ }
+
+ is ArrayList<*> -> {
+ // convert it to an arraylist and check the first item
+ val kval = kv.value as ArrayList<*>
+ when (kval.firstOrNull()) {
+ // if it's a string:
+ is String -> {
+ // rebuild it into a set
+ val demand = mutableSetOf()
+ for (k in kval) {
+ demand.add(k as String)
+ }
+ // add it
+ putStringSet(kv.key, demand)
+ }
+ // otherwise:
+ else -> {
+ // if the list is empty, just add an empty set
+ if (kval.size == 0) {
+ putStringSet(kv.key, setOf())
+ }
+ // otherwise throw an error
+ else {
+ throw IllegalStateException(
+ "preference ${kv.key} has a value of unknown type ${kv.value!!::class.simpleName} (${kv.value!!::class.jvmName})"
+ )
+ }
+ }
+ }
+ }
+
+ else -> {
+ if (kv.value != null) {
+ throw IllegalStateException(
+ "preference ${kv.key} has a value of unknown type ${kv.value!!::class.simpleName} (${kv.value!!::class.jvmName})"
+ )
+ }
+ }
+ }
+ }
+ }
+ // post-validate the backup to resolve errors with the configuration
+ postValidatePrefs(map)
+ }
+
+ private suspend fun raiseBackupError(error: BackupLoadError) {
+ // raise an error to the UI on the default thread
+ withContext(Dispatchers.Default) {
+ _uiState.update {
+ it.copy(
+ error = error
+ )
+ }
+ }
+ }
+
+ private suspend fun postValidatePrefs(map: Map) {
+ // if a serp provider is in the backup, but it's not in the global list:
+ if (map.containsKey("serpProvider")) {
+ if (!Global.allSerpProviders.contains(map["serpProvider"])) {
+ // delete the serp provider
+ Global.sharedPreferences.edit {
+ remove("serpProvider")
+ }
+ // raise an error
+ raiseBackupError(BackupLoadError.UNKNOWN_SERP_PROVIDER)
+ return
+ }
+ }
+ }
+
+ private suspend fun loadPreferences(backup: PreferenceBackup) = withContext(Dispatchers.IO) {
+ if (backup.preferences != null) {
+ loadPreferencesFromMap(backup.preferences)
+ }
+ else if (backup.encryptedPreferences != null) {
+ if (backup.gpbVersion == 1) {
+ // v1 encrypted preferences are encrypted incorrectly due to gugal not
+ // saving the IV, it was fixed in v2
+ throw IllegalStateException("v1 encrypted prefs cannot be decrypted")
+ }
+
+ if (_uiState.value.password == null) {
+ throw IllegalStateException("preferences are encrypted but no password was provided")
+ }
+
+ val instruction = backup.encryptedPreferences.split(',')
+ if (instruction.size < 4) {
+ throw IllegalStateException("encrypted preference string must contain at least 4 parts (received ${instruction.size})")
+ }
+
+ // derive a key from the password
+ val key = KeyDerivation.generateHash(_uiState.value.password!!, instruction[1])
+ // use that key to decrypt the blob
+ val aes = AESCrypt(key)
+ val prefJSON = aes.decrypt(instruction[2], instruction[3])
+ // decode the prefs from the blob as JSON and load them
+ val gson = Gson()
+ val prefs = gson.fromJson