Author: Adam Evyčędo <git@apiote.xyz>
WIP add dynamic client
%!v(PANIC=String method: strings: negative Repeat count)
diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 016c28aaf0aecc722b14e7e4aea59b09c736009d..293265bf5157e96543fa4a98ec9fec291f2801f3 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -20,13 +20,13 @@ android { namespace = "xyz.apiote.bimba.czwek" // NOTE apksigner with `--alignment-preserved` https://gitlab.com/fdroid/fdroiddata/-/issues/3299#note_1989808414 - compileSdk = 35 - buildToolsVersion = "35.0.1" + compileSdk = 36 + buildToolsVersion = "36.0.0" defaultConfig { applicationId = "xyz.apiote.bimba.czwek" minSdk = 21 - targetSdk = 35 + targetSdk = 36 versionCode = 37 versionName = "3.9.0" @@ -109,6 +109,7 @@ implementation("androidx.core:core-splashscreen:1.0.1") implementation("com.google.openlocationcode:openlocationcode:1.0.4") implementation("org.osmdroid:osmdroid-android:6.1.20") implementation("org.yaml:snakeyaml:2.4") + implementation("com.charleskorn.kaml:kaml:0.73.0") implementation("androidx.activity:activity-ktx:1.10.1") implementation("com.otaliastudios:zoomlayout:1.9.0") implementation("dev.bandb.graphview:graphview:0.8.1") diff --git a/app/src/main/java/xyz/apiote/bimba/czwek/Bimba.kt b/app/src/main/java/xyz/apiote/bimba/czwek/Bimba.kt index d695be178ef80a558d1d7d8c6f56ad77db757a28..1b051f3ee33eb91fa6ee8f471ba11360b8ab4a70 100644 --- a/app/src/main/java/xyz/apiote/bimba/czwek/Bimba.kt +++ b/app/src/main/java/xyz/apiote/bimba/czwek/Bimba.kt @@ -51,11 +51,11 @@ channelName = getString(R.string.acra_notification_channel) channelDescription = getString(R.string.acra_notification_channel_description) channelImportance = NotificationManagerCompat.IMPORTANCE_DEFAULT sendButtonText = getString(R.string.send) - resSendButtonIcon = R.drawable.send + resSendButtonIcon = R.drawable.send // TODO black? discardButtonText = getString(R.string.discard) - resDiscardButtonIcon = R.drawable.discard + resDiscardButtonIcon = R.drawable.discard // TODO colorOnSurface? sendWithCommentButtonText = getString(R.string.send_with_comment) - resSendWithCommentButtonIcon = R.drawable.comment + resSendWithCommentButtonIcon = R.drawable.comment // TODO colorOnSurface? commentPrompt = getString(R.string.acra_notification_comment) } } diff --git a/app/src/main/java/xyz/apiote/bimba/czwek/api/Api.kt b/app/src/main/java/xyz/apiote/bimba/czwek/api/Api.kt index 4ff3d6d839d708b8e84d21b774678bd6a4e2dc97..e13244cf159cd8744c2f4eded77b43f13b68e7d0 100644 --- a/app/src/main/java/xyz/apiote/bimba/czwek/api/Api.kt +++ b/app/src/main/java/xyz/apiote/bimba/czwek/api/Api.kt @@ -9,9 +9,15 @@ import android.content.Context.MODE_PRIVATE import android.net.ConnectivityManager import android.net.NetworkCapabilities import android.os.Build +import android.util.Base64 +import android.util.Log +import androidx.core.content.edit +import com.charleskorn.kaml.Yaml +import com.charleskorn.kaml.decodeFromStream +import com.github.jershell.kbson.KBson import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext -import org.openapitools.client.infrastructure.ServerError +import kotlinx.serialization.Serializable import org.openapitools.client.infrastructure.ServerException import xyz.apiote.bimba.czwek.R import xyz.apiote.bimba.czwek.settings.feeds.FeedsSettings @@ -27,53 +33,103 @@ // todo [3.2] constants // todo [3.2] split api files to classes files +@Serializable +data class TrafficServer( + val url: String, + val description: String, + val seatsRequired: List<String>, + var feeds: FeedsSettings = FeedsSettings(mutableMapOf()) +) + +@Serializable +data class Traffic( + val authEndpoint: String, + val servers: List<TrafficServer>, + var selectedServer: Int = 0 +) + +@Serializable data class Server( - val host: String, - val token: String, - val feeds: FeedsSettings, - val apiPath: String + var host: String, + var traffic: Traffic? ) { companion object { - const val DEFAULT = "bimba.apiote.xyz" - const val HOST_KEY = "host" - const val TOKEN_KEY = "token" + const val DEFAULT = "bimba.app" + @Deprecated("should use traffic's url") const val API_PATH_KEY = "apiPath" + const val SERVER_KEY = "server" fun get(context: Context): Server { val preferences = context.getSharedPreferences("shp", MODE_PRIVATE) - val apiPath = preferences.getString(API_PATH_KEY, "")!! - val feeds = FeedsSettings.load(context, apiPath) - val host = preferences.getString(HOST_KEY, DEFAULT)!! - return Server( - host, preferences.getString(TOKEN_KEY, "")!!, - feeds, apiPath + val savedServer = preferences.getString(SERVER_KEY, null) + return savedServer?.let { + KBson().load(serializer(), Base64.decode(it, Base64.DEFAULT)) + } ?: Server(DEFAULT, null) + } + } + + fun save(context: Context) { + val savedServer = Base64.encodeToString(KBson().dump(serializer(), this), Base64.DEFAULT) + val preferences = context.getSharedPreferences("shp", MODE_PRIVATE) + preferences.edit { + putString(SERVER_KEY, savedServer) + } + } + + fun getSelectedServer(): TrafficServer { + return traffic!!.servers[traffic!!.selectedServer] + } + + suspend fun getTraffic(context: Context, force: Boolean = false) { + if (traffic != null && !force) { + Log.i("Server", "traffic already not null") + return + } + val result = try { + rawRequest( + URL("${hostWithScheme(host)}/.well-known/traffic.yml"), context, emptyArray() ) + } catch (_: MalformedURLException) { + Result(null, Error(0, R.string.error_url, R.drawable.error_url)) + } + if (result.error != null) { + Log.e("Server", "while getting traffic: ${result.error}") + } else { + val traffic = Yaml.default.decodeFromStream<Traffic>(result.stream!!) + if (this.traffic == null) { + this.traffic = traffic + } else { + val servers = traffic.servers.mapIndexed { i, server -> + if (server.url == this.traffic!!.servers[i].url) { + server.feeds = this.traffic!!.servers[i].feeds + } + if (this.getSelectedServer().url == server.url) { + traffic.selectedServer = i + } + server + } + this.traffic = Traffic(traffic.authEndpoint, servers, traffic.selectedServer) + } + + save(context) } } + } data class Result(val stream: InputStream?, val error: Error?) data class Error(val statusCode: Int, val stringResource: Int, val imageResource: Int) { companion object { - fun fromTransitous(e: ServerException): Error = Error(e.statusCode, R.string.error, R.drawable.error_other) - } -} - -suspend fun getBimba(context: Context, server: Server): Result { - return try { - rawRequest( - URL("${hostWithScheme(server.host)}/.well-known/traffic-api"), server, context, emptyArray() - ) - } catch (e: MalformedURLException) { - Result(null, Error(0, R.string.error_url, R.drawable.error_url)) + fun fromTransitous(e: ServerException): Error = + Error(e.statusCode, R.string.error, R.drawable.error_other) } } suspend fun getFeeds(context: Context, server: Server): Result { return try { rawRequest( - URL("${server.apiPath}/"), server, context, arrayOf(1u, 2u) + URL(server.getSelectedServer().url), context, arrayOf(1u, 2u) ) } catch (_: MalformedURLException) { Result(null, Error(0, R.string.error_url, R.drawable.error_url)) @@ -187,7 +243,7 @@ } } suspend fun rawRequest( - url: URL, server: Server, context: Context, responseVersion: Array<UInt> + url: URL, context: Context, responseVersion: Array<UInt> ): Result { if (!isNetworkAvailable(context)) { return Result(null, Error(0, R.string.error_offline, R.drawable.error_net)) @@ -198,7 +254,7 @@ setRequestProperty( "User-Agent", "${context.getString(R.string.applicationId)}/${context.getString(R.string.versionName)} (${Build.VERSION.SDK_INT})" ) - setRequestProperty("X-Bimba-Token", server.token) + // TODO put token in authorization responseVersion.forEach { addRequestProperty("Accept", "application/$it+bare") } } try { @@ -208,7 +264,7 @@ } else { val (string, image) = mapHttpError(c.responseCode) Result(c.errorStream, Error(c.responseCode, string, image)) } - } catch (e: IOException) { + } catch (_: IOException) { Result(null, Error(0, R.string.error_connecting, R.drawable.error_server)) } } @@ -243,7 +299,7 @@ feeds: String? ): Result { return withContext(Dispatchers.IO) { val url = URL( // todo [3.2] scheme, host, path, constructed query - "${server.apiPath}/${feeds?.ifEmpty { server.feeds.getIDs() } ?: server.feeds.getIDs()}/$resource${ + "${server.getSelectedServer().url}/${feeds?.ifEmpty { server.getSelectedServer().feeds.getIDs() } ?: server.getSelectedServer().feeds.getIDs()}/$resource${ if (item == null) { "" } else { @@ -259,7 +315,7 @@ }" }.joinToString("&", "?") }" ) - rawRequest(url, server, context, responseVersion) + rawRequest(url, context, responseVersion) } } diff --git a/app/src/main/java/xyz/apiote/bimba/czwek/onboarding/FirstRunActivity.kt b/app/src/main/java/xyz/apiote/bimba/czwek/onboarding/FirstRunActivity.kt index e7828395a0b090e4ce33797eb61fa5fc2c789f1d..b0f61f116875bbc7cfcb79d669dadd0bd54c4ab2 100644 --- a/app/src/main/java/xyz/apiote/bimba/czwek/onboarding/FirstRunActivity.kt +++ b/app/src/main/java/xyz/apiote/bimba/czwek/onboarding/FirstRunActivity.kt @@ -15,7 +15,10 @@ import androidx.core.content.edit import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen import androidx.work.OneTimeWorkRequest import androidx.work.WorkManager +import kotlinx.coroutines.MainScope +import kotlinx.coroutines.launch import xyz.apiote.bimba.czwek.R +import xyz.apiote.bimba.czwek.api.Server import xyz.apiote.bimba.czwek.dashboard.MainActivity import xyz.apiote.bimba.czwek.repo.migrateDB import xyz.apiote.bimba.czwek.settings.DownloadCitiesWorker @@ -26,7 +29,10 @@ override fun onCreate(savedInstanceState: Bundle?) { installSplashScreen() super.onCreate(savedInstanceState) - migrateFeedsSettings(this) + MainScope().launch { + Server.get(this@FirstRunActivity).getTraffic(this@FirstRunActivity) + migrateFeedsSettings(this@FirstRunActivity) + } migrateDB(this) createNotificationChannels() diff --git a/app/src/main/java/xyz/apiote/bimba/czwek/onboarding/OnboardingActivity.kt b/app/src/main/java/xyz/apiote/bimba/czwek/onboarding/OnboardingActivity.kt index f934bd2eb9c7e7f084b89b67dd6e0cf0d18e97c1..d932fead370affcb8d82e39ed1f374c0e5a135dd 100644 --- a/app/src/main/java/xyz/apiote/bimba/czwek/onboarding/OnboardingActivity.kt +++ b/app/src/main/java/xyz/apiote/bimba/czwek/onboarding/OnboardingActivity.kt @@ -6,17 +6,22 @@ package xyz.apiote.bimba.czwek.onboarding import android.content.Intent import android.content.SharedPreferences -import android.net.Uri import android.os.Bundle import android.util.Log +import android.view.KeyEvent +import android.view.View +import android.widget.TextView import androidx.activity.enableEdgeToEdge import androidx.activity.result.contract.ActivityResultContracts import androidx.appcompat.app.AppCompatActivity import androidx.core.content.edit +import androidx.core.net.toUri import androidx.core.view.ViewCompat import androidx.core.view.WindowInsetsCompat import androidx.core.view.updatePadding import androidx.core.widget.addTextChangedListener +import kotlinx.coroutines.MainScope +import kotlinx.coroutines.launch import net.openid.appauth.AuthState import net.openid.appauth.AuthorizationException import net.openid.appauth.AuthorizationRequest @@ -24,10 +29,17 @@ import net.openid.appauth.AuthorizationResponse import net.openid.appauth.AuthorizationService import net.openid.appauth.AuthorizationServiceConfiguration import net.openid.appauth.AuthorizationServiceConfiguration.RetrieveConfigurationCallback +import net.openid.appauth.ClientSecretPost +import net.openid.appauth.RegistrationRequest +import net.openid.appauth.RegistrationResponse import net.openid.appauth.ResponseTypeValues +import net.openid.appauth.TokenResponse +import org.acra.ktx.sendWithAcra import xyz.apiote.bimba.czwek.api.Server +import xyz.apiote.bimba.czwek.dashboard.MainActivity import xyz.apiote.bimba.czwek.databinding.ActivityOnboardingBinding import xyz.apiote.bimba.czwek.settings.feeds.FeedChooserActivity +import java.security.MessageDigest class OnboardingActivity : AppCompatActivity() { @@ -35,15 +47,19 @@ private var _binding: ActivityOnboardingBinding? = null private val binding get() = _binding!! private lateinit var preferences: SharedPreferences private lateinit var authState: AuthState + private lateinit var clientSecret: String + private lateinit var server: Server + private var authError: Exception? = null - private val activityLauncher = + private val authLauncher = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { - if (!FirstRunActivity.getFirstRun(this)) { - finish() - } + val resp = AuthorizationResponse.fromIntent(it.data!!) + val ex = AuthorizationException.fromIntent(it.data) + onAuthorization(resp, ex) } override fun onCreate(savedInstanceState: Bundle?) { + // TODO insets enableEdgeToEdge() super.onCreate(savedInstanceState) _binding = ActivityOnboardingBinding.inflate(layoutInflater) @@ -63,17 +79,44 @@ binding.server.editText!!.addTextChangedListener { editable -> binding.accountButton.isEnabled = !editable.isNullOrBlank() binding.anonymousButton.isEnabled = !editable.isNullOrBlank() + binding.continueButton.isEnabled = !editable.isNullOrBlank() } - if (!FirstRunActivity.getFirstRun(this)) { - Server.get(this).let { server -> - binding.server.editText!!.setText(server.host) + binding.defaultServerSwitch.setOnCheckedChangeListener { _, checked -> + binding.server.visibility = if (checked) { + binding.accountButton.isEnabled = true + binding.anonymousButton.isEnabled = true + binding.continueButton.isEnabled = true + View.GONE + } else { + View.VISIBLE } } + binding.reportButton.setOnClickListener { + authError?.sendWithAcra() // TODO or silently + } + + server = Server.get(this) + binding.server.editText!!.setText(server.host) + binding.defaultServerSwitch.isChecked = server.host != Server.DEFAULT + binding.accountButton.setOnClickListener { - // TODO OIDC flow https://github.com/openid/AppAuth-Android - oidcFlow() + // NOTE OIDC flow https://github.com/openid/AppAuth-Android + startAuth() + } + + binding.server.editText!!.setOnKeyListener { _, keyCode, event -> + if (keyCode == KeyEvent.KEYCODE_ENTER && event.action == KeyEvent.ACTION_UP && binding.continueButton.visibility == View.VISIBLE) { + doContinue() + true + } else { + false + } + } + + binding.continueButton.setOnClickListener { + doContinue() } binding.anonymousButton.setOnClickListener { @@ -81,81 +124,153 @@ moveOn() } } + @OptIn(ExperimentalStdlibApi::class) + private fun doContinue() { + val md = MessageDigest.getInstance("SHA-256") + md.update(binding.server.editText!!.text.toString().encodeToByteArray()) + val hex = md.digest().toHexString() + if (hex == "0621b618c336648cd99dcf85bec68b61741c8679a27ef13eef7f91cc24e2eb80") { + binding.continueButton.visibility = View.GONE + binding.anonymousButton.visibility = View.VISIBLE + binding.accountButton.visibility = View.VISIBLE + binding.server.editText!!.setText(server.host) + // TODO save shibboleet + } else { + moveOn() + } + } + private fun setServer(hostname: String) { - preferences.edit(true) { - putString(Server.HOST_KEY, hostname) + server.host = hostname + server.save(this) + } + + private fun startAuth() { + setServer( + if (binding.defaultServerSwitch.isChecked) { + Server.DEFAULT + } else { + binding.server.editText!!.text.toString() + } + ) + + binding.errorText.visibility = View.GONE + binding.reportButton.visibility = View.GONE + + MainScope().launch { + server.getTraffic(this@OnboardingActivity, true) + + AuthorizationServiceConfiguration.fetchFromIssuer( + server.traffic!!.authEndpoint.toUri(), + RetrieveConfigurationCallback { serviceConfiguration, ex -> + onRetrieveConfiguration(serviceConfiguration, ex) + } + ) } } private fun moveOn() { - // TODO test 401/403 - // TODO show rate-limit info, terms and privacy + /* + if (server.traffic!!.servers.all { it.seatsRequired.indexOf(user.seat) == -1 }) { + // TODO show forbidden info + } + */ + binding.server.editText?.text?.toString()?.let { serverAddress -> setServer(serverAddress) } - activityLauncher.launch(Intent(this, FeedChooserActivity::class.java)) + if (!FirstRunActivity.getFirstRun(this)) { + startActivity(Intent(this, FeedChooserActivity::class.java)) + } + finish() + } + + private fun onAuthError(exception: Exception) { + binding.errorText.visibility = View.VISIBLE + binding.reportButton.visibility = View.VISIBLE + authError = exception + } + + private fun onRetrieveConfiguration( + config: AuthorizationServiceConfiguration?, + exception: AuthorizationException? + ) { + if (exception != null) { + Log.e("OIDC", "failed to fetch configuration") + onAuthError(exception) + return + } + authState = AuthState(config!!) + + val registrationRequest = RegistrationRequest.Builder( + config, + listOf(CALLBACK) + ).build() + + val service = AuthorizationService(this) + service.performRegistrationRequest( + registrationRequest + ) { response, ex -> + onPerformRegistration(response, ex) + } } - private fun oidcFlow() { - AuthorizationServiceConfiguration.fetchFromIssuer( - Uri.parse("https://oauth-bimba.apiote.xyz"), - RetrieveConfigurationCallback { serviceConfiguration, ex -> - if (ex != null) { - Log.e("OIDC", "failed to fetch configuration") - return@RetrieveConfigurationCallback - } - authState = AuthState(serviceConfiguration!!) + fun onPerformRegistration(response: RegistrationResponse?, ex: AuthorizationException?) { + if (ex != null) { + Log.e("OIDC", "failed to register client") + onAuthError(ex) + return + } + authState.update(response) + clientSecret = response!!.clientSecret!! - /* - {"client_id":"46e1be27-a747-4507-a1d4-c383f137745f","client_name":"Bimba","client_secret_expires_at":0,"client_uri":"https://bimba.app","contacts":["questions@bimba.app"],"created_at":"2024-09-09T12:41:30Z","grant_types":["authorization_code","refresh_token"],"jwks":{},"logo_uri":"https://bimba.app/bimba.svg","metadata":{},"owner":"","policy_uri":"","redirect_uris":["bimba://callback"],"registration_access_token":"ory_at_xQWn3kMRuev5ZmCM_BgwnuOo57GRKWz-7LAA8EH2OTA.GXZCCf7XCg8fIwjpGZjZGdgJPMSsMSM9E-6B7bE8nB0","registration_client_uri":"https://oauth-bimba.apiote.xyz/oauth2/register/","request_object_signing_alg":"RS256","response_types":["code","id_token"],"scope":"openid offline","skip_consent":false,"skip_logout_consent":false,"subject_type":"public","token_endpoint_auth_method":"none","tos_uri":"","updated_at":"2024-09-09T14:41:29.982386+02:00","userinfo_signed_response_alg":"none"} - */ - val authRequest = AuthorizationRequest.Builder( - serviceConfiguration, - "46e1be27-a747-4507-a1d4-c383f137745f", - ResponseTypeValues.CODE, - Uri.parse("bimba://callback") - ) - .setScope("openid offline") - .build(); + val authRequest = AuthorizationRequest.Builder( + authState.authorizationServiceConfiguration!!, + response.clientId, + ResponseTypeValues.CODE, + CALLBACK + ) + .setScope(SCOPES) + .build() - val authIntent = AuthorizationService(this).getAuthorizationRequestIntent(authRequest) - startActivityForResult(authIntent, RC_AUTH) - }) + val authIntent = AuthorizationService(this).getAuthorizationRequestIntent(authRequest) + authLauncher.launch(authIntent) } - override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) { - super.onActivityResult(requestCode, resultCode, data) - if (requestCode == RC_AUTH) { - val resp = AuthorizationResponse.fromIntent(data!!) - val ex = AuthorizationException.fromIntent(data) - if (resp != null) { - authState.update(resp, ex) - AuthorizationService(this).performTokenRequest( - resp.createTokenExchangeRequest() - ) { tokenResponse, tokenException -> - if (tokenResponse != null) { - authState.update(resp, ex) - getSharedPreferences("auth", MODE_PRIVATE).edit { - putString("state", authState.jsonSerializeString()) - } - if (tokenResponse.idToken == null) { - // TODO openid is needed; show some dialogue - } - Log.i("OIDC", "got token: id: ${tokenResponse.idToken}\n access: ${tokenResponse.accessToken}\n params: ${tokenResponse.additionalParameters}") - // TODO moveOn() - } else { - // FIXME token exchange failed: Client authentication failed (e.g., unknown client, no client authentication included, or unsupported authentication method). - Log.e("OIDC", "token exchange failed: ${tokenException!!.message}") - tokenException.printStackTrace() - } - } - } else { - Log.e("OIDC", "auth failed: ${ex!!.message}") + fun onAuthorization(resp: AuthorizationResponse?, ex: AuthorizationException?) { + authState.update(resp, ex) + if (resp != null) { + AuthorizationService(this).performTokenRequest( + resp.createTokenExchangeRequest(), + ClientSecretPost(clientSecret) + ) { tokenResponse, tokenException -> + onTokenRequest(tokenResponse, tokenException) } + } else { + Log.e("OIDC", "auth failed: ${ex!!.message}") + onAuthError(ex) + } + } + + fun onTokenRequest(tokenResponse: TokenResponse?, tokenException: AuthorizationException?) { + authState.update(tokenResponse, tokenException) + if (tokenResponse != null) { + getSharedPreferences("auth", MODE_PRIVATE).edit { + putString("state", authState.jsonSerializeString()) + } + Log.i( + "OIDC", + "got token: id: ${tokenResponse.idToken}\n access: ${tokenResponse.accessToken}\n params: ${tokenResponse.additionalParameters}" + ) + moveOn() + } else { + Log.e("OIDC", "token exchange failed: ${tokenException!!.message}") + onAuthError(tokenException) } } companion object { const val PREFERENCES_NAME = "shp" const val IN_FEEDS_TRANSACTION = "inFeedsTransaction" - const val RC_AUTH = 42 + val CALLBACK = "bimba://callback".toUri() + const val SCOPES = "openid offline_access email profile" } } \ No newline at end of file diff --git a/app/src/main/java/xyz/apiote/bimba/czwek/repo/OfflineRepository.kt b/app/src/main/java/xyz/apiote/bimba/czwek/repo/OfflineRepository.kt index 2906b7096f0283669d3c38ab9c82ae4e94e96838..ef9c999cb5141653559b50d3b9f650e8551ed502 100644 --- a/app/src/main/java/xyz/apiote/bimba/czwek/repo/OfflineRepository.kt +++ b/app/src/main/java/xyz/apiote/bimba/czwek/repo/OfflineRepository.kt @@ -10,13 +10,13 @@ import androidx.core.content.edit import androidx.core.database.sqlite.transaction import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext +import xyz.apiote.bimba.czwek.R import xyz.apiote.bimba.czwek.api.Server import xyz.apiote.fruchtfleisch.Reader import xyz.apiote.fruchtfleisch.Writer import java.io.File import java.net.URLEncoder import java.time.LocalDate -import xyz.apiote.bimba.czwek.R class OfflineRepository(context: Context) : Repository { private val db = @@ -24,7 +24,7 @@ SQLiteDatabase.openOrCreateDatabase(context.getDatabasePath("favourites").path, null) fun saveFeedCache(context: Context, feedInfos: Map<String, FeedInfo>) { val file = File( - context.filesDir, URLEncoder.encode(Server.get(context).apiPath, "utf-8") + context.filesDir, URLEncoder.encode(Server.get(context).getSelectedServer().url, "utf-8") ) context.getSharedPreferences("offlineFeeds", Context.MODE_PRIVATE).edit { putInt("version", FeedInfo.VERSION.toInt()) @@ -153,7 +153,7 @@ server: Server ): Map<String, FeedInfo>? { val file = File( context.filesDir, withContext(Dispatchers.IO) { - URLEncoder.encode(server.apiPath, "utf-8") + URLEncoder.encode(server.getSelectedServer().url, "utf-8") } ) if (!file.exists()) { diff --git a/app/src/main/java/xyz/apiote/bimba/czwek/repo/OnlineRepository.kt b/app/src/main/java/xyz/apiote/bimba/czwek/repo/OnlineRepository.kt index fa1dcca8a770916ab39206fa0dbf10fff6ab0995..315865ff8a8204d9b3ae0c4173d6041605ba8460 100644 --- a/app/src/main/java/xyz/apiote/bimba/czwek/repo/OnlineRepository.kt +++ b/app/src/main/java/xyz/apiote/bimba/czwek/repo/OnlineRepository.kt @@ -173,7 +173,7 @@ context: Context, bl: Position, tr: Position, ): List<Locatable>? { - val transitousQueryables = if (Server.get(context).feeds.transitousEnabled()) { + val transitousQueryables = if (Server.get(context).getSelectedServer().feeds.transitousEnabled()) { locateTransitousQueryables( Position(bl.latitude, tr.longitude), Position(tr.latitude, bl.longitude), @@ -187,7 +187,7 @@ }.filterNotNull() } else { null } - val bimbaQueryables = if (Server.get(context).feeds.bimbaEnabled()) { + val bimbaQueryables = if (Server.get(context).getSelectedServer().feeds.bimbaEnabled()) { val result = xyz.apiote.bimba.czwek.api.getLocatablesIn( context, Server.get(context), @@ -276,12 +276,12 @@ override suspend fun queryQueryables( query: String, context: Context, feedID: String? ): List<Queryable>? { - val transitousQueryables = if (Server.get(context).feeds.transitousEnabled() || feedID == "transitous") { + val transitousQueryables = if (Server.get(context).getSelectedServer().feeds.transitousEnabled() || feedID == "transitous") { getTransitousQueryables(query, context) } else { null } - val bimbaQueryables = if (Server.get(context).feeds.bimbaEnabled() && feedID == null) { // TODO select bimba feed + val bimbaQueryables = if (Server.get(context).getSelectedServer().feeds.bimbaEnabled() && feedID == null) { // TODO select bimba feed getQueryables(query, null, context, "query") } else { null @@ -296,12 +296,12 @@ override suspend fun locateQueryables( position: Position, context: Context ): List<Queryable>? { - val transitousQueryables = if (Server.get(context).feeds.transitousEnabled()) { + val transitousQueryables = if (Server.get(context).getSelectedServer().feeds.transitousEnabled()) { locateTransitousQueryables(position, context) } else { null } - val bimbaQueryables = if (Server.get(context).feeds.bimbaEnabled()) { + val bimbaQueryables = if (Server.get(context).getSelectedServer().feeds.bimbaEnabled()) { getQueryables(null, position, context, "locate") } else { null diff --git a/app/src/main/java/xyz/apiote/bimba/czwek/settings/feeds/FeedChooserActivity.kt b/app/src/main/java/xyz/apiote/bimba/czwek/settings/feeds/FeedChooserActivity.kt index b712d05ae2e23872fe8dea94d7303af43f68b91d..9c780e3faebf372fba4a489632c5a7da4fa38fcc 100644 --- a/app/src/main/java/xyz/apiote/bimba/czwek/settings/feeds/FeedChooserActivity.kt +++ b/app/src/main/java/xyz/apiote/bimba/czwek/settings/feeds/FeedChooserActivity.kt @@ -119,7 +119,8 @@ } } private fun moveOn() { - viewModel.settings.value?.save(this, Server.get(this)) + val server = Server.get(this) + server.getSelectedServer().feeds = viewModel.settings.value?: FeedsSettings(mutableMapOf()) val preferences = getSharedPreferences(PREFERENCES_NAME, MODE_PRIVATE) preferences.edit(true) { putBoolean(OnboardingActivity.IN_FEEDS_TRANSACTION, false) diff --git a/app/src/main/java/xyz/apiote/bimba/czwek/settings/feeds/FeedSettings.kt b/app/src/main/java/xyz/apiote/bimba/czwek/settings/feeds/FeedSettings.kt index 0407d15f16954e42e900ebad7f43b6e268603eec..dcc0f07126ac59d2fa825e5516f78478e0cbb118 100644 --- a/app/src/main/java/xyz/apiote/bimba/czwek/settings/feeds/FeedSettings.kt +++ b/app/src/main/java/xyz/apiote/bimba/czwek/settings/feeds/FeedSettings.kt @@ -5,11 +5,16 @@ package xyz.apiote.bimba.czwek.settings.feeds import android.content.Context +import android.content.Context.MODE_PRIVATE +import android.util.Log import androidx.appcompat.app.AppCompatActivity import androidx.core.content.edit import com.github.jershell.kbson.KBson +import kotlinx.coroutines.MainScope +import kotlinx.coroutines.launch import kotlinx.serialization.Serializable import xyz.apiote.bimba.czwek.api.Server +import xyz.apiote.bimba.czwek.api.Server.Companion.DEFAULT import java.net.URLEncoder @Serializable @@ -21,22 +26,26 @@ fun getIDs() = activeFeeds().filter { it != "transitous" }.joinToString(",") fun transitousEnabled() = activeFeeds().contains("transitous") fun bimbaEnabled() = activeFeeds().filter { it != "transitous" }.isNotEmpty() + @Deprecated("use save on Server") fun save(context: Context, server: Server) { val doc = KBson().dump(serializer(), this).toHexString() val feedsPreferences = context.getSharedPreferences(PREFERENCES_NAME, AppCompatActivity.MODE_PRIVATE) feedsPreferences.edit { - val key = URLEncoder.encode(server.apiPath, "utf-8") + val key = URLEncoder.encode(server.getSelectedServer().url, "utf-8") putString(key, doc) } } companion object { const val PREFERENCES_NAME = "feeds_settings" - fun load(context: Context, apiPath: String = Server.get(context).apiPath): FeedsSettings { + + @Deprecated("load with Server") + fun load( + context: Context, apiPath: String = Server.get(context).getSelectedServer().url + ): FeedsSettings { val doc = context.getSharedPreferences( - PREFERENCES_NAME, - Context.MODE_PRIVATE + PREFERENCES_NAME, Context.MODE_PRIVATE ).getString(URLEncoder.encode(apiPath, "utf-8"), null) return doc?.let { KBson().load(serializer(), doc.hexToByteArray()) } ?: FeedsSettings( mutableMapOf() @@ -47,28 +56,38 @@ } @Serializable data class FeedSettings( - val enabled: Boolean, - val useOnline: Boolean + val enabled: Boolean, val useOnline: Boolean ) fun migrateFeedsSettings(context: Context, server: Server = Server.get(context)) { - val shp = - context.getSharedPreferences( - URLEncoder.encode(server.apiPath, "utf-8"), - AppCompatActivity.MODE_PRIVATE - ) - if (shp.all.isEmpty()) { + if (server.traffic == null) { + Log.w("migrateFeedsSetting", "server.traffic is null, not migrating") return } - - val feedsSettings = FeedsSettings(mutableMapOf()) - shp.all.forEach { (feedID, enabled) -> - if (enabled as Boolean) { - feedsSettings.settings[feedID] = FeedSettings(enabled = true, useOnline = true) + val preferences = context.getSharedPreferences("shp", MODE_PRIVATE) + var apiPath = preferences.getString(Server.API_PATH_KEY, DEFAULT)!! + val shp = context.getSharedPreferences( + URLEncoder.encode(apiPath, "utf-8"), MODE_PRIVATE + ) + if (!shp.all.isEmpty()) { + val feedsSettings = FeedsSettings(mutableMapOf()) + shp.all.forEach { (feedID, enabled) -> + if (enabled as Boolean) { + feedsSettings.settings[feedID] = FeedSettings(enabled = true, useOnline = true) + } + } + shp.edit { + clear() } + val ix = server.traffic!!.servers.indexOfFirst { it.url == apiPath } + server.traffic!!.servers[ix].feeds = feedsSettings + server.save(context) + return } - shp.edit { - clear() + val feedsSettings = FeedsSettings.load(context, apiPath) + if (feedsSettings.settings.isNotEmpty()) { + val ix = server.traffic!!.servers.indexOfFirst { it.url == apiPath } + server.traffic!!.servers[ix].feeds = feedsSettings + server.save(context) } - feedsSettings.save(context, server) } diff --git a/app/src/main/res/layout/account.xml b/app/src/main/res/layout/account.xml index f0edee1eea3f31121dbf710167a97dcb94d810e0..2235486a61ebe9937195aaca26ce4f5252a621cd 100644 --- a/app/src/main/res/layout/account.xml +++ b/app/src/main/res/layout/account.xml @@ -1,4 +1,9 @@ <?xml version="1.0" encoding="utf-8"?> +<!-- +SPDX-FileCopyrightText: Adam Evyčędo + +SPDX-License-Identifier: GPL-3.0-or-later +--> <androidx.coordinatorlayout.widget.CoordinatorLayout xmlns:android="http://schemas.android.com/apk/res/android" xmlns:app="http://schemas.android.com/apk/res-auto" xmlns:tools="http://schemas.android.com/tools" diff --git a/app/src/main/res/layout/account_edit.xml b/app/src/main/res/layout/account_edit.xml index a912c25b2175296d2b4ce9b1baf3d2de9879facb..f7f268ca9eadceaa66962eb81863197f4b58db00 100644 --- a/app/src/main/res/layout/account_edit.xml +++ b/app/src/main/res/layout/account_edit.xml @@ -1,4 +1,9 @@ <?xml version="1.0" encoding="utf-8"?> +<!-- +SPDX-FileCopyrightText: Adam Evyčędo + +SPDX-License-Identifier: GPL-3.0-or-later +--> <androidx.coordinatorlayout.widget.CoordinatorLayout xmlns:android="http://schemas.android.com/apk/res/android" xmlns:app="http://schemas.android.com/apk/res-auto" xmlns:tools="http://schemas.android.com/tools" diff --git a/app/src/main/res/layout/activity_onboarding.xml b/app/src/main/res/layout/activity_onboarding.xml index dc3a1d72dbf4dd6190d42ec6984161a265996cbe..673d9280fc2ca82b685acde1b72e0f263d1d13d9 100644 --- a/app/src/main/res/layout/activity_onboarding.xml +++ b/app/src/main/res/layout/activity_onboarding.xml @@ -1,4 +1,8 @@ -<?xml version="1.0" encoding="utf-8"?> +<?xml version="1.0" encoding="utf-8"?><!-- +SPDX-FileCopyrightText: Adam Evyčędo + +SPDX-License-Identifier: GPL-3.0-or-later +--> <androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android" xmlns:app="http://schemas.android.com/apk/res-auto" xmlns:tools="http://schemas.android.com/tools" @@ -29,57 +33,126 @@ app:layout_constraintStart_toStartOf="@+id/logo" app:layout_constraintTop_toBottomOf="@+id/logo" /> <com.google.android.material.textview.MaterialTextView - android:layout_width="0dp" + android:id="@+id/defaultServerSwitchLabel" + android:layout_width="wrap_content" android:layout_height="wrap_content" android:layout_marginStart="8dp" + android:text="@string/use_default_server" + android:textAppearance="@style/TextAppearance.Material3.HeadlineSmall" + app:layout_constraintBottom_toTopOf="@+id/guideline40" + app:layout_constraintStart_toStartOf="parent" /> + + <com.google.android.material.materialswitch.MaterialSwitch + android:id="@+id/defaultServerSwitch" + android:layout_width="wrap_content" + android:layout_height="wrap_content" android:layout_marginEnd="8dp" - android:layout_marginBottom="16dp" - android:text="@string/seatbelts_everyone" - android:textAlignment="center" - android:textAppearance="@style/TextAppearance.Material3.BodySmall" - android:textStyle="italic" - app:layout_constraintBottom_toBottomOf="parent" + android:checked="true" + app:layout_constraintBottom_toBottomOf="@+id/defaultServerSwitchLabel" app:layout_constraintEnd_toEndOf="parent" - app:layout_constraintStart_toStartOf="parent" /> + app:layout_constraintTop_toTopOf="@+id/defaultServerSwitchLabel" /> + + <androidx.constraintlayout.widget.Guideline + android:id="@+id/guideline40" + android:layout_width="wrap_content" + android:layout_height="wrap_content" + android:orientation="horizontal" + app:layout_constraintGuide_percent=".4" /> <com.google.android.material.textfield.TextInputLayout android:id="@+id/server" - android:layout_width="400dp" + android:layout_width="0dp" android:layout_height="wrap_content" android:layout_marginStart="16dp" - android:layout_marginTop="64dp" + android:layout_marginTop="16dp" android:layout_marginEnd="16dp" android:hint="@string/bimba_server_address_hint" + android:visibility="gone" app:layout_constraintEnd_toEndOf="parent" app:layout_constraintStart_toStartOf="parent" - app:layout_constraintTop_toBottomOf="@+id/app_name"> + app:layout_constraintTop_toBottomOf="@+id/defaultServerSwitchLabel"> <com.google.android.material.textfield.TextInputEditText android:layout_width="match_parent" android:layout_height="wrap_content" - android:text="bimba.apiote.xyz" - tools:ignore="HardcodedText" /> + tools:text="bimba.app" /> </com.google.android.material.textfield.TextInputLayout> + <androidx.constraintlayout.widget.Guideline + android:id="@+id/guideline75" + android:layout_width="wrap_content" + android:layout_height="wrap_content" + android:orientation="horizontal" + app:layout_constraintGuide_percent=".75" /> + <Button android:id="@+id/account_button" android:layout_width="wrap_content" android:layout_height="wrap_content" - android:layout_marginTop="64dp" android:text="@string/log_in_or_sign_up" + android:visibility="gone" app:layout_constraintEnd_toEndOf="@+id/server" app:layout_constraintStart_toStartOf="@+id/server" - app:layout_constraintTop_toBottomOf="@+id/server" /> + app:layout_constraintTop_toTopOf="@+id/guideline75" /> <Button android:id="@+id/anonymous_button" android:layout_width="wrap_content" android:layout_height="wrap_content" - android:layout_marginTop="16dp" android:text="@string/continue_without_account" + android:visibility="gone" app:layout_constraintEnd_toEndOf="@+id/account_button" app:layout_constraintStart_toStartOf="@+id/account_button" app:layout_constraintTop_toBottomOf="@+id/account_button" /> + + <Button + android:id="@+id/continue_button" + android:layout_width="wrap_content" + android:layout_height="wrap_content" + android:text="@string/continue_" + app:layout_constraintEnd_toEndOf="@+id/account_button" + app:layout_constraintStart_toStartOf="@+id/account_button" + app:layout_constraintTop_toTopOf="@+id/account_button" /> + + <com.google.android.material.textview.MaterialTextView + android:id="@+id/errorText" + android:layout_width="wrap_content" + android:layout_height="wrap_content" + android:layout_marginStart="16dp" + android:text="@string/error_while_logging_in" + android:textAppearance="@style/TextAppearance.Material3.BodyMedium" + android:textColor="?colorError" + android:visibility="gone" + app:layout_constraintBottom_toTopOf="@id/seatbelts" + app:layout_constraintStart_toStartOf="parent" + app:layout_constraintTop_toBottomOf="@id/anonymous_button" /> + + <Button + android:id="@+id/reportButton" + style="@style/Widget.Material3.Button.TextButton" + android:layout_width="wrap_content" + android:layout_height="wrap_content" + android:layout_marginEnd="16dp" + android:text="@string/send_a_report" + android:visibility="gone" + app:layout_constraintBottom_toBottomOf="@+id/errorText" + app:layout_constraintEnd_toEndOf="parent" + app:layout_constraintTop_toTopOf="@+id/errorText" /> + + <com.google.android.material.textview.MaterialTextView + android:id="@+id/seatbelts" + android:layout_width="0dp" + android:layout_height="wrap_content" + android:layout_marginStart="8dp" + android:layout_marginEnd="8dp" + android:layout_marginBottom="16dp" + android:text="@string/seatbelts_everyone" + android:textAlignment="center" + android:textAppearance="@style/TextAppearance.Material3.BodySmall" + android:textStyle="italic" + app:layout_constraintBottom_toBottomOf="parent" + app:layout_constraintEnd_toEndOf="parent" + app:layout_constraintStart_toStartOf="parent" /> </androidx.constraintlayout.widget.ConstraintLayout> \ No newline at end of file diff --git a/app/src/main/res/menu/account.xml b/app/src/main/res/menu/account.xml index fc3b526989d14d93d2619016ddafe280fbb33d60..75bbdd346d97ad31bbea22657f24529c6790bb48 100644 --- a/app/src/main/res/menu/account.xml +++ b/app/src/main/res/menu/account.xml @@ -1,4 +1,9 @@ <?xml version="1.0" encoding="utf-8"?> +<!-- +SPDX-FileCopyrightText: Adam Evyčędo + +SPDX-License-Identifier: GPL-3.0-or-later +--> <menu xmlns:android="http://schemas.android.com/apk/res/android" xmlns:app="http://schemas.android.com/apk/res-auto"> <item diff --git a/app/src/main/res/menu/account_edit.xml b/app/src/main/res/menu/account_edit.xml index 960bf340e27c216bd3f962889429766ecce83464..e8687e73ae1f7bc54ba57f18336688381907d80b 100644 --- a/app/src/main/res/menu/account_edit.xml +++ b/app/src/main/res/menu/account_edit.xml @@ -1,4 +1,9 @@ <?xml version="1.0" encoding="utf-8"?> +<!-- +SPDX-FileCopyrightText: Adam Evyčędo + +SPDX-License-Identifier: GPL-3.0-or-later +--> <menu xmlns:android="http://schemas.android.com/apk/res/android" xmlns:app="http://schemas.android.com/apk/res-auto"> <item diff --git a/app/src/main/res/menu/feeds_menu.xml b/app/src/main/res/menu/feeds_menu.xml index bc87c01e295ede32e965dc043da89413cc2cefc3..a8f7f52b851ccd769f462f6b93e893f9161dd9ac 100644 --- a/app/src/main/res/menu/feeds_menu.xml +++ b/app/src/main/res/menu/feeds_menu.xml @@ -1,4 +1,9 @@ <?xml version="1.0" encoding="utf-8"?> +<!-- +SPDX-FileCopyrightText: Adam Evyčędo + +SPDX-License-Identifier: GPL-3.0-or-later +--> <menu xmlns:android="http://schemas.android.com/apk/res/android" xmlns:app="http://schemas.android.com/apk/res-auto"> <item diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 1ed54e2a0934810e8fbf093cc95e1de3b396c571..dc091d1e59727db5a7f93bb256745feee53bc2a0 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -331,4 +331,7 @@Using <string name="name">Name</string> <string name="resend_verification_code">Resend verification code</string> <string name="title_account">Account</string> + <string name="use_default_server">Use default server</string> + <string name="error_while_logging_in">Error while logging in</string> + <string name="send_a_report">Send a report</string> </resources> diff --git a/app/src/main/res/values-en-rGB/strings.xml b/app/src/main/res/values-en-rGB/strings.xml index 0439f88d8155b6d4e6a196da0a663ec31eb810cd..b4e5c22ee8094ca3cc8e09e926dccab7bcfbf712 100644 --- a/app/src/main/res/values-en-rGB/strings.xml +++ b/app/src/main/res/values-en-rGB/strings.xml @@ -313,4 +313,7 @@end of journey’s leg <string name="use_as_origin">use as origin</string> <string name="use_as_destination">use as destination</string> <string name="here">here</string> + <string name="use_default_server">Use default server</string> + <string name="error_while_logging_in">Error while logging in</string> + <string name="send_a_report">Send a report</string> </resources> diff --git a/app/src/main/res/values-en-rUS/strings.xml b/app/src/main/res/values-en-rUS/strings.xml index 3b80001be91904e0c223070fd8bb031f75a20534..ec0c8ce5e17a09cf5e2e3b347e827c4bf90fdad9 100644 --- a/app/src/main/res/values-en-rUS/strings.xml +++ b/app/src/main/res/values-en-rUS/strings.xml @@ -311,4 +311,7 @@end of journey’s leg <string name="use_as_origin">use as origin</string> <string name="use_as_destination">use as destination</string> <string name="here">here</string> + <string name="use_default_server">Use default server</string> + <string name="error_while_logging_in">Error while logging in</string> + <string name="send_a_report">Send a report</string> </resources> \ No newline at end of file