From c8b04dadb6d750b6eeaefbc62fa89f9912f344f0 Mon Sep 17 00:00:00 2001 From: Armin Date: Fri, 2 Jun 2023 18:08:13 +0200 Subject: [PATCH] [RFR-329] Add incentives and group selection to r4r UI (#51) * [RFR-329] Add achievements ui * Only show achievements in R4R UI * Calculate and show progress * Format valid until into local format * Fake get voucher request * Add copy to clipboard handler * [RFR-451] Add voucher details * [RFR-461] Check voucher count * [RFR-450] Ask group during registration * [STAD-515] Upgrade SDK to 7.7.2 * [RFR-478] Set km goal * Refactor code * [RFR-467] Get auth token for incentives requests * [RFR-467] Call hard-coded staging incentives API * Cleanup and documentation * [RFR-506] Inject incentives API URL --- backend | 2 +- build.gradle | 5 +- gradle.properties.template | 4 +- .../kotlin/de/cyface/app/LoginActivity.kt | 3 +- .../de/cyface/app/RegistrationActivity.kt | 3 +- ui/r4r/build.gradle | 2 + .../main/kotlin/de/cyface/app/r4r/Group.kt | 53 +++++ .../kotlin/de/cyface/app/r4r/LoginActivity.kt | 3 +- .../kotlin/de/cyface/app/r4r/MainActivity.kt | 28 +++ .../de/cyface/app/r4r/RegistrationActivity.kt | 45 +++- .../main/res/layout/activity_registration.xml | 7 + ui/r4r/src/main/res/values-de/groups.xml | 9 + ui/r4r/src/main/res/values-de/strings.xml | 4 + ui/r4r/src/main/res/values-it/groups.xml | 9 + ui/r4r/src/main/res/values-it/strings.xml | 4 + ui/r4r/src/main/res/values/groups.xml | 9 + ui/r4r/src/main/res/values/strings.xml | 4 + utils/build.gradle | 2 + utils/src/main/AndroidManifest.xml | 5 + .../cyface/app/utils/trips/DetailsFragment.kt | 5 +- .../cyface/app/utils/trips/TripsFragment.kt | 215 +++++++++++++++++- .../app/utils/trips/incentives/Incentives.kt | 196 ++++++++++++++++ .../res/drawable/baseline_verified_24.xml | 5 + .../baseline_workspace_premium_24.xml | 5 + utils/src/main/res/layout/fragment_trips.xml | 196 ++++++++++++++-- utils/src/main/res/values-de/strings.xml | 12 + utils/src/main/res/values-it/strings.xml | 12 + utils/src/main/res/values/strings.xml | 12 + 28 files changed, 825 insertions(+), 34 deletions(-) create mode 100644 ui/r4r/src/main/kotlin/de/cyface/app/r4r/Group.kt create mode 100644 ui/r4r/src/main/res/values-de/groups.xml create mode 100644 ui/r4r/src/main/res/values-it/groups.xml create mode 100644 ui/r4r/src/main/res/values/groups.xml create mode 100644 utils/src/main/kotlin/de/cyface/app/utils/trips/incentives/Incentives.kt create mode 100644 utils/src/main/res/drawable/baseline_verified_24.xml create mode 100644 utils/src/main/res/drawable/baseline_workspace_premium_24.xml diff --git a/backend b/backend index fab52d78..f8d9d822 160000 --- a/backend +++ b/backend @@ -1 +1 @@ -Subproject commit fab52d784a2bf49d7121c65cb87685a8be1b7c8c +Subproject commit f8d9d822fe64c8bd69fe140539ba1e6f6c8cc39e diff --git a/build.gradle b/build.gradle index feedbed2..1c12e7a7 100644 --- a/build.gradle +++ b/build.gradle @@ -21,7 +21,7 @@ * * @author Armin Schnabel * @author Klemens Muthmann - * @version 2.3.0 + * @version 2.4.0 * @since 1.0.0 */ @@ -55,7 +55,7 @@ ext { */ // Cyface dependencies - cyfaceAndroidBackendVersion = "7.7.1" // Also update submodule commit ref + cyfaceAndroidBackendVersion = "7.7.2" // Also update submodule commit ref cyfaceUtilsVersion = "3.3.7" cyfaceEnergySettingsVersion = "3.3.3" // Also update submodule commit ref cyfaceCameraServiceVersion = "4.1.11" // Also update submodule commit ref @@ -78,6 +78,7 @@ ext { roomVersion = "2.5.1" lifecycleVersion = "2.6.1" navigationVersion = "2.5.3" + volleyVersion = "1.2.1" // Kotlin components coroutinesVersion = "1.6.4" diff --git a/gradle.properties.template b/gradle.properties.template index 981eeafe..079df35b 100644 --- a/gradle.properties.template +++ b/gradle.properties.template @@ -36,7 +36,8 @@ githubUser= githubToken= cyface.api= -cyface.auth_api +cyface.auth_api= +cyface.incentives_api= google.maps_api_key= google.maps-api_key.r4r= @@ -50,6 +51,7 @@ cyface.emulator_api= cyface.staging_api= cyface.staging_auth_api= +cyface.staging_incentives_api= cyface.staging_user= cyface.staging_password= diff --git a/ui/cyface/src/main/kotlin/de/cyface/app/LoginActivity.kt b/ui/cyface/src/main/kotlin/de/cyface/app/LoginActivity.kt index 313ffe39..bfa010f7 100644 --- a/ui/cyface/src/main/kotlin/de/cyface/app/LoginActivity.kt +++ b/ui/cyface/src/main/kotlin/de/cyface/app/LoginActivity.kt @@ -333,7 +333,8 @@ class LoginActivity : AccountAuthenticatorActivity() { private fun setServerUrl() { val storedServer = preferences!!.getString(AUTH_ENDPOINT_URL_SETTINGS_KEY, null) val server = BuildConfig.authServer - Validate.notNull(server) + @Suppress("KotlinConstantConditions") + Validate.isTrue(server != "null") if (storedServer == null || storedServer != server) { Log.d( TAG, diff --git a/ui/cyface/src/main/kotlin/de/cyface/app/RegistrationActivity.kt b/ui/cyface/src/main/kotlin/de/cyface/app/RegistrationActivity.kt index d733caa4..84963c85 100644 --- a/ui/cyface/src/main/kotlin/de/cyface/app/RegistrationActivity.kt +++ b/ui/cyface/src/main/kotlin/de/cyface/app/RegistrationActivity.kt @@ -345,7 +345,8 @@ class RegistrationActivity : FragmentActivity() /* HCaptcha requires FragmentAct val stored = preferences!!.getString(AUTH_ENDPOINT_URL_SETTINGS_KEY, null) val currentUrl = BuildConfig.authServer - Validate.notNull(currentUrl) + @Suppress("KotlinConstantConditions") + Validate.isTrue(currentUrl != "null") if (stored == null || stored != currentUrl) { Log.d(TAG, "Updating Auth API URL from $stored to $currentUrl") val editor = preferences!!.edit() diff --git a/ui/r4r/build.gradle b/ui/r4r/build.gradle index 153d381f..fbdc4993 100644 --- a/ui/r4r/build.gradle +++ b/ui/r4r/build.gradle @@ -92,6 +92,7 @@ android { // Staging buildConfigField "String", "cyfaceServer", "\"${project.findProperty('cyface.staging_api')}\"" buildConfigField "String", "authServer", "\"${project.findProperty('cyface.staging_auth_api')}\"" + buildConfigField "String", "incentivesServer", "\"${project.findProperty('cyface.staging_incentives_api')}\"" buildConfigField "String", "testLogin", "\"${project.findProperty('cyface.staging_user')}\"" buildConfigField "String", "testPassword", "\"${project.findProperty('cyface.staging_password')}\"" manifestPlaceholders = [usesCleartextTraffic:"false"] @@ -109,6 +110,7 @@ android { // signingConfig is set by the CI buildConfigField "String", "cyfaceServer", "\"${project.findProperty('cyface.api')}\"" buildConfigField "String", "authServer", "\"${project.findProperty('cyface.auth_api')}\"" + buildConfigField "String", "incentivesServer", "\"${project.findProperty('cyface.incentives_api')}\"" manifestPlaceholders = [usesCleartextTraffic:"false"] } } diff --git a/ui/r4r/src/main/kotlin/de/cyface/app/r4r/Group.kt b/ui/r4r/src/main/kotlin/de/cyface/app/r4r/Group.kt new file mode 100644 index 00000000..20262163 --- /dev/null +++ b/ui/r4r/src/main/kotlin/de/cyface/app/r4r/Group.kt @@ -0,0 +1,53 @@ +/* + * Copyright 2023 Cyface GmbH + * + * This file is part of the Cyface SDK for Android. + * + * The Cyface SDK for Android 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. + * + * The Cyface SDK for Android 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 the Cyface SDK for Android. If not, see . + */ +package de.cyface.app.r4r + +/** + * The groups the user can choose from during registration. + * + * This way we can enable group-specific achievements like vouchers. + * + * @author Armin Schnabel + * @version 1.0.0 + * @since 3.3.0 + * @property databaseIdentifier The [String] which represents the enumeration value in the database. + * @property spinnerText The [String] which is shown in the `Spinner`. + */ +enum class Group(private val databaseIdentifier: String, val spinnerText: String) { + // Keep the spinnerText in sync with `res/values/groups.xml` + // Don't change the databaseIdentifier. + @Suppress("SpellCheckingInspection") + NONE_GERMAN("guest", "Kommune auswählen"), + NONE_ENGLISH("guest", "Choose municipality"), + + @Suppress("SpellCheckingInspection") + KOETHEN("koethen", "Köthen"), + SCHKEUDITZ("schkeuditz", "Schkeuditz"); + + companion object { + private val spinnerTextValues = Group.values().associateBy(Group::spinnerText) + + /** + * Returns the [Group] from the selected spinner text value. + * + * @param spinnerText The selected spinner text. + */ + fun fromSpinnerText(spinnerText: String) = spinnerTextValues[spinnerText] + } +} \ No newline at end of file diff --git a/ui/r4r/src/main/kotlin/de/cyface/app/r4r/LoginActivity.kt b/ui/r4r/src/main/kotlin/de/cyface/app/r4r/LoginActivity.kt index d8d20044..da4d52e6 100644 --- a/ui/r4r/src/main/kotlin/de/cyface/app/r4r/LoginActivity.kt +++ b/ui/r4r/src/main/kotlin/de/cyface/app/r4r/LoginActivity.kt @@ -320,7 +320,8 @@ class LoginActivity : AccountAuthenticatorActivity() { private fun setServerUrl() { val storedServer = preferences!!.getString(AUTH_ENDPOINT_URL_SETTINGS_KEY, null) val server = BuildConfig.authServer - Validate.notNull(server) + @Suppress("KotlinConstantConditions") + Validate.isTrue(server != "null") if (storedServer == null || storedServer != server) { Log.d( TAG, diff --git a/ui/r4r/src/main/kotlin/de/cyface/app/r4r/MainActivity.kt b/ui/r4r/src/main/kotlin/de/cyface/app/r4r/MainActivity.kt index 9407018f..a5067188 100644 --- a/ui/r4r/src/main/kotlin/de/cyface/app/r4r/MainActivity.kt +++ b/ui/r4r/src/main/kotlin/de/cyface/app/r4r/MainActivity.kt @@ -46,6 +46,8 @@ import de.cyface.app.utils.ServiceProvider import de.cyface.app.utils.SharedConstants import de.cyface.app.utils.SharedConstants.DEFAULT_SENSOR_FREQUENCY import de.cyface.app.utils.SharedConstants.PREFERENCES_SYNCHRONIZATION_KEY +import de.cyface.app.utils.trips.incentives.Incentives +import de.cyface.app.utils.trips.incentives.Incentives.Companion.INCENTIVES_ENDPOINT_URL_SETTINGS_KEY import de.cyface.datacapturing.CyfaceDataCapturingService import de.cyface.datacapturing.DataCapturingListener import de.cyface.datacapturing.exception.SetupException @@ -57,6 +59,7 @@ import de.cyface.energy_settings.TrackingSettings.showProblematicManufacturerDia import de.cyface.energy_settings.TrackingSettings.showRestrictedBackgroundProcessingWarningDialog import de.cyface.persistence.model.ParcelableGeoLocation import de.cyface.synchronization.Constants.AUTH_TOKEN_TYPE +import de.cyface.synchronization.SyncService import de.cyface.synchronization.WiFiSurveyor import de.cyface.uploader.exception.SynchronisationException import de.cyface.utils.DiskConsumption @@ -206,6 +209,10 @@ class MainActivity : AppCompatActivity(), ServiceProvider { // Not showing manufacturer warning on each resume to increase likelihood that it's read showProblematicManufacturerDialog(this, false, SUPPORT_EMAIL) + + // Inject the Incentives API URL into the preferences, as the `Incentives` from `utils` + // cannot reach the `ui.rfr.BuildConfig`. + setIncentivesServerUrl() } /** @@ -322,4 +329,25 @@ class MainActivity : AppCompatActivity(), ServiceProvider { Validate.isTrue(existingAccounts.size < 2, "More than one account exists.") return existingAccounts.isNotEmpty() } + + /** + * As long as the server URL is hardcoded we want to reset it when it's different from the + * default URL set in the [BuildConfig]. If not, hardcoded updates would not have an + * effect. + */ + private fun setIncentivesServerUrl() { + val storedServer = preferences.getString(INCENTIVES_ENDPOINT_URL_SETTINGS_KEY, null) + val server = BuildConfig.incentivesServer + @Suppress("KotlinConstantConditions") + Validate.isTrue(server != "null") + if (storedServer == null || storedServer != server) { + Log.d( + TAG, + "Updating Cyface Incentives API URL from " + storedServer + "to" + server + ) + val editor = preferences.edit() + editor.putString(INCENTIVES_ENDPOINT_URL_SETTINGS_KEY, server) + editor.apply() + } + } } \ No newline at end of file diff --git a/ui/r4r/src/main/kotlin/de/cyface/app/r4r/RegistrationActivity.kt b/ui/r4r/src/main/kotlin/de/cyface/app/r4r/RegistrationActivity.kt index d05dbded..9880eb42 100644 --- a/ui/r4r/src/main/kotlin/de/cyface/app/r4r/RegistrationActivity.kt +++ b/ui/r4r/src/main/kotlin/de/cyface/app/r4r/RegistrationActivity.kt @@ -28,8 +28,11 @@ import android.util.Patterns import android.view.View import android.view.View.GONE import android.view.View.VISIBLE +import android.widget.AdapterView +import android.widget.ArrayAdapter import android.widget.Button import android.widget.ProgressBar +import android.widget.Spinner import androidx.fragment.app.FragmentActivity import com.google.android.material.textfield.TextInputEditText import com.google.android.material.textview.MaterialTextView @@ -58,10 +61,11 @@ import java.util.regex.Pattern * A registration screen that offers registration via email/password and captcha. * * @author Armin Schnabel - * @version 1.0.0 + * @version 1.1.0 * @since 3.3.0 */ -class RegistrationActivity : FragmentActivity() /* HCaptcha requires FragmentActivity */ { +class RegistrationActivity : FragmentActivity() /* HCaptcha requires FragmentActivity */, + AdapterView.OnItemSelectedListener { private lateinit var context: WeakReference private var preferences: SharedPreferences? = null @@ -80,6 +84,12 @@ class RegistrationActivity : FragmentActivity() /* HCaptcha requires FragmentAct private var emailInput: TextInputEditText? = null private var passwordInput: TextInputEditText? = null private var passwordConfirmationInput: TextInputEditText? = null + private lateinit var groupSpinner: Spinner + + /** + * The group selected by the user during registration. + */ + private var group: Group? = null private var messageView: MaterialTextView? = null /** @@ -102,10 +112,22 @@ class RegistrationActivity : FragmentActivity() /* HCaptcha requires FragmentAct preferences = PreferenceManager.getDefaultSharedPreferences(this) setServerUrl() // TODO [CY-3735]: via Android's settings - // Set up the login form + // Set up the form emailInput = findViewById(R.id.input_email) passwordInput = findViewById(R.id.input_password) passwordConfirmationInput = findViewById(R.id.input_password_confirmation) + groupSpinner = findViewById(R.id.group_spinner) + ArrayAdapter.createFromResource( + this, + R.array.groups, + android.R.layout.simple_spinner_item + ).also { adapter -> + // Specify the layout to use when the list of choices appears + adapter.setDropDownViewResource(android.R.layout.simple_spinner_dropdown_item) + // Apply the adapter to the spinner + groupSpinner.adapter = adapter + } + groupSpinner.onItemSelectedListener = this messageView = findViewById(R.id.registration_message) registrationButton = findViewById(R.id.registration_button) registrationButton!!.setOnClickListener { attemptRegistration() } @@ -118,8 +140,16 @@ class RegistrationActivity : FragmentActivity() /* HCaptcha requires FragmentAct setupHCaptcha(hCaptcha) } - override fun onDestroy() { - super.onDestroy() + override fun onItemSelected(parent: AdapterView<*>?, view: View?, position: Int, id: Long) { + val selected = parent!!.getItemAtPosition(position).toString() + val group = Group.fromSpinnerText(selected) + Validate.notNull(group, "Unknown spinner text: $selected") + this.group = group + } + + override fun onNothingSelected(parent: AdapterView<*>?) { + // Another interface callback + Log.d(TAG, "onNothingSelected") } private fun hCaptchaConfig(): HCaptchaConfig { @@ -173,6 +203,7 @@ class RegistrationActivity : FragmentActivity() /* HCaptcha requires FragmentAct Validate.notNull(emailInput!!.text) Validate.notNull(passwordInput!!.text) Validate.notNull(passwordConfirmationInput!!.text) + Validate.notNull(group) val email = emailInput!!.text.toString() val password = passwordInput!!.text.toString() val passwordConfirmation = passwordConfirmationInput!!.text.toString() @@ -260,6 +291,7 @@ class RegistrationActivity : FragmentActivity() /* HCaptcha requires FragmentAct } } } + else -> { reportError(e) } @@ -348,7 +380,8 @@ class RegistrationActivity : FragmentActivity() /* HCaptcha requires FragmentAct val stored = preferences!!.getString(AUTH_ENDPOINT_URL_SETTINGS_KEY, null) val currentUrl = BuildConfig.authServer - Validate.notNull(currentUrl) + @Suppress("KotlinConstantConditions") + Validate.isTrue(currentUrl != "null") if (stored == null || stored != currentUrl) { Log.d(TAG, "Updating Auth API URL from $stored to $currentUrl") val editor = preferences!!.edit() diff --git a/ui/r4r/src/main/res/layout/activity_registration.xml b/ui/r4r/src/main/res/layout/activity_registration.xml index f78ac448..c359b43d 100644 --- a/ui/r4r/src/main/res/layout/activity_registration.xml +++ b/ui/r4r/src/main/res/layout/activity_registration.xml @@ -111,6 +111,13 @@ android:inputType="textPassword" /> + + + + + + + + Kommune auswählen + Köthen + Schkeuditz + + \ No newline at end of file diff --git a/ui/r4r/src/main/res/values-de/strings.xml b/ui/r4r/src/main/res/values-de/strings.xml index f96c18b2..fd2cbe7a 100644 --- a/ui/r4r/src/main/res/values-de/strings.xml +++ b/ui/r4r/src/main/res/values-de/strings.xml @@ -1,5 +1,9 @@ + + + Kommune auswählen + Datenerfassung inaktiv \ No newline at end of file diff --git a/ui/r4r/src/main/res/values-it/groups.xml b/ui/r4r/src/main/res/values-it/groups.xml new file mode 100644 index 00000000..81d12dc1 --- /dev/null +++ b/ui/r4r/src/main/res/values-it/groups.xml @@ -0,0 +1,9 @@ + + + + + Choose municipality + Köthen + Schkeuditz + + \ No newline at end of file diff --git a/ui/r4r/src/main/res/values-it/strings.xml b/ui/r4r/src/main/res/values-it/strings.xml index f4a7e58e..f2a08b98 100644 --- a/ui/r4r/src/main/res/values-it/strings.xml +++ b/ui/r4r/src/main/res/values-it/strings.xml @@ -1,5 +1,9 @@ + + + Choose Municipality + capturing inactive \ No newline at end of file diff --git a/ui/r4r/src/main/res/values/groups.xml b/ui/r4r/src/main/res/values/groups.xml new file mode 100644 index 00000000..81d12dc1 --- /dev/null +++ b/ui/r4r/src/main/res/values/groups.xml @@ -0,0 +1,9 @@ + + + + + Choose municipality + Köthen + Schkeuditz + + \ No newline at end of file diff --git a/ui/r4r/src/main/res/values/strings.xml b/ui/r4r/src/main/res/values/strings.xml index 9ce09f1b..19b4b36f 100644 --- a/ui/r4r/src/main/res/values/strings.xml +++ b/ui/r4r/src/main/res/values/strings.xml @@ -1,6 +1,10 @@ Ready4Robots + + + Choose Municipality + capturing inactive \ No newline at end of file diff --git a/utils/build.gradle b/utils/build.gradle index 0e77cffb..438306ea 100644 --- a/utils/build.gradle +++ b/utils/build.gradle @@ -125,6 +125,8 @@ dependencies { implementation "androidx.navigation:navigation-fragment-ktx:$rootProject.ext.navigationVersion" // Charts implementation "com.github.PhilJay:MPAndroidChart:$rootProject.ext.chartVersion" + // Http Requests + implementation "com.android.volley:volley:$rootProject.ext.volleyVersion" // Cyface dependencies implementation "de.cyface:android-utils:$rootProject.ext.cyfaceUtilsVersion" diff --git a/utils/src/main/AndroidManifest.xml b/utils/src/main/AndroidManifest.xml index a41355cb..6f940c9b 100644 --- a/utils/src/main/AndroidManifest.xml +++ b/utils/src/main/AndroidManifest.xml @@ -10,4 +10,9 @@ + + + + + \ No newline at end of file diff --git a/utils/src/main/kotlin/de/cyface/app/utils/trips/DetailsFragment.kt b/utils/src/main/kotlin/de/cyface/app/utils/trips/DetailsFragment.kt index 805f3403..855111b4 100644 --- a/utils/src/main/kotlin/de/cyface/app/utils/trips/DetailsFragment.kt +++ b/utils/src/main/kotlin/de/cyface/app/utils/trips/DetailsFragment.kt @@ -42,7 +42,7 @@ import kotlin.math.roundToInt * The [Fragment] which shows details about a single, finished measurement. * * @author Armin Schnabel - * @version 1.0.0 + * @version 1.0.1 * @since 3.2.0 */ class DetailsFragment : Fragment() { @@ -137,10 +137,11 @@ class DetailsFragment : Fragment() { // Chart val chart = root.findViewById(R.id.chart) as LineChart val altitudes = persistence.loadAltitudes(measurementId) - if (altitudes == null || altitudes.isEmpty()) { + if (altitudes.isNullOrEmpty()) { binding.elevationProfileTitle.text = getString(R.string.elevation_profile_no_data) chart.visibility = GONE } else { + // We could also show the relative elevation profile (starting at elevation 0) val allEntries = ArrayList>() var x = 1 val values = altitudes.sumOf { trackAltitudes -> trackAltitudes.count() } diff --git a/utils/src/main/kotlin/de/cyface/app/utils/trips/TripsFragment.kt b/utils/src/main/kotlin/de/cyface/app/utils/trips/TripsFragment.kt index 941d8180..edb87629 100644 --- a/utils/src/main/kotlin/de/cyface/app/utils/trips/TripsFragment.kt +++ b/utils/src/main/kotlin/de/cyface/app/utils/trips/TripsFragment.kt @@ -18,33 +18,56 @@ */ package de.cyface.app.utils.trips +import android.content.ClipData +import android.content.ClipboardManager import android.content.Context import android.content.SharedPreferences import android.os.Bundle +import android.os.Handler +import android.os.Looper import android.preference.PreferenceManager +import android.util.Log import android.view.LayoutInflater import android.view.View +import android.view.View.GONE +import android.view.View.VISIBLE import android.view.ViewGroup import android.widget.Toast import androidx.activity.result.ActivityResultLauncher import androidx.activity.result.contract.ActivityResultContracts +import androidx.core.content.ContextCompat.getSystemService import androidx.fragment.app.Fragment import androidx.fragment.app.viewModels import androidx.lifecycle.Lifecycle import androidx.recyclerview.widget.DividerItemDecoration import androidx.recyclerview.widget.LinearLayoutManager +import de.cyface.app.utils.R import de.cyface.app.utils.ServiceProvider +import de.cyface.app.utils.SharedConstants.TAG import de.cyface.app.utils.databinding.FragmentTripsBinding +import de.cyface.app.utils.trips.incentives.Incentives +import de.cyface.app.utils.trips.incentives.Incentives.Companion.INCENTIVES_ENDPOINT_URL_SETTINGS_KEY import de.cyface.datacapturing.CyfaceDataCapturingService +import de.cyface.persistence.model.Measurement +import de.cyface.synchronization.CyfaceAuthenticator +import de.cyface.synchronization.SyncService +import de.cyface.uploader.DefaultAuthenticator +import de.cyface.utils.Validate +import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.GlobalScope import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import java.lang.Double.min import java.lang.ref.WeakReference +import java.text.SimpleDateFormat +import java.util.Locale +import kotlin.math.roundToInt /** * The [Fragment] which shows all finished measurements to the user. * * @author Armin Schnabel - * @version 1.0.0 + * @version 1.0.1 * @since 3.2.0 */ class TripsFragment : Fragment() { @@ -74,6 +97,17 @@ class TripsFragment : Fragment() { */ private var tracker: androidx.recyclerview.selection.SelectionTracker? = null + /** + * defined by E-Mail from Matthias Koss, 23.05.23 + */ + @Suppress("SpellCheckingInspection") + private val distanceGoalKm = 15.0 + + /** + * The API to get the voucher data from or `null` when no such things show be shown to the user. + */ + private var incentives: Incentives? = null + /** * The [TripsViewModel] which holds the UI data. */ @@ -109,6 +143,16 @@ class TripsFragment : Fragment() { } else { throw RuntimeException("Context does not support the Fragment, implement ServiceProvider") } + val authApi = authApi(requireContext()) + val authenticator = DefaultAuthenticator(authApi) + + // Load incentivesUrl - only send requests in RFR app + val rfr = requireContext().packageName.equals("de.cyface.app.r4r") + if (rfr) { + val incentivesApi = incentivesApi(requireContext()) + this.incentives = + Incentives(CyfaceAuthenticator(requireContext(), authenticator), incentivesApi) + } } override fun onCreateView( @@ -144,9 +188,72 @@ class TripsFragment : Fragment() { ) tripsList.addItemDecoration(divider) + // Check voucher availability + if (incentives != null) { + GlobalScope.launch { + withContext(Dispatchers.IO) { + try { + incentives!!.availableVouchers( + requireContext(), + { response -> + val availableVouchers = response.getInt("vouchers") + if (availableVouchers > 0) { + Handler(Looper.getMainLooper()).post { + // Can be null when switching tab before response returns + _binding?.achievementsVouchersLeft?.text = + getString(R.string.voucher_left, availableVouchers) + _binding?.achievements?.visibility = VISIBLE + } + } + }, + { + Handler(Looper.getMainLooper()).post { + Toast.makeText( + context, + "Gutscheinprüfung fehlgeschlagen: ${it.message}", + Toast.LENGTH_LONG + ).show() + } + }) + } catch (e: Exception) { + Handler(Looper.getMainLooper()).post { + Toast.makeText( + context, + "Gutscheinprüfung nicht möglich", + Toast.LENGTH_LONG + ).show() + } + Log.e(TAG, "availableVouchers crashed", e) + } + } + } + } + // Update adapters with the updates from the ViewModel tripsViewModel.measurements.observe(viewLifecycleOwner) { measurements -> measurements?.let { adapter.submitList(it) } + + // Show achievements progress + if (incentives != null) { + val totalDistanceKm = totalDistanceKm(measurements) + val progress = min(totalDistanceKm / distanceGoalKm * 100.0, 100.0) + + if (progress < 100) { + showProgress(progress, distanceGoalKm, totalDistanceKm) + } else { + // Show request voucher button + binding.achievementsProgress.visibility = GONE + binding.achievementsReceived.visibility = GONE + binding.achievementsUnlocked.visibility = VISIBLE + binding.achievementsUnlockedButton.setOnClickListener { + GlobalScope.launch { + withContext(Dispatchers.IO) { + showVoucher() + } + } + } + } + } } // Add items to menu (top right) @@ -163,6 +270,112 @@ class TripsFragment : Fragment() { return binding.root } + private fun showProgress( + progress: Double, + @Suppress("SameParameterValue") distanceGoalKm: Double, + totalDistanceKm: Double + ) { + binding.achievementsUnlocked.visibility = GONE + binding.achievementsReceived.visibility = GONE + binding.achievementsProgress.visibility = VISIBLE + val missingKm = distanceGoalKm - totalDistanceKm + binding.achievementsProgressContent.text = + getString(R.string.achievements_progress, missingKm, distanceGoalKm) + binding.achievementsProgressBar.progress = progress.roundToInt() + } + + private fun totalDistanceKm(measurements: List): Double { + var totalDistanceKm = 0.0 + measurements.forEach { measurement -> + val distanceKm = measurement.distance.div(1000.0) + totalDistanceKm += distanceKm + } + return totalDistanceKm + } + + /** + * Reads the Auth URL from the preferences. + * + * @param context The `Context` required to read the preferences + * @return The URL as string + */ + private fun authApi(context: Context): String { + val preferences = PreferenceManager.getDefaultSharedPreferences(context) + val apiEndpoint = preferences.getString(SyncService.AUTH_ENDPOINT_URL_SETTINGS_KEY, null) + Validate.notNull( + apiEndpoint, + "TripsFragment: Auth url not available. Please set the applications server url preference." + ) + return apiEndpoint!! + } + + /** + * Reads the Incentives API URL from the preferences. + * + * @param context The `Context` required to read the preferences + * @return The URL as string + */ + private fun incentivesApi(context: Context): String { + val preferences = PreferenceManager.getDefaultSharedPreferences(context) + val apiEndpoint = preferences.getString(INCENTIVES_ENDPOINT_URL_SETTINGS_KEY, null) + Validate.notNull( + apiEndpoint, + "TripsFragment: Incentives url not available. Please set the applications server url preference." + ) + return apiEndpoint!! + } + + private fun showVoucher() { + try { + incentives!!.voucher( + requireContext(), + { response -> + val code = response.getString("code") + val until = response.getString("until") + Handler(Looper.getMainLooper()).post { + binding.achievementsUnlocked.visibility = GONE + binding.achievementsProgress.visibility = GONE + binding.achievementsReceived.visibility = VISIBLE + binding.achievementsReceivedContent.text = + getString(R.string.voucher_code_is, code) + @Suppress("SpellCheckingInspection") + val format = SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss", Locale.GERMANY) + val validUntil = format.parse(until) + val untilText = + SimpleDateFormat.getDateInstance( + SimpleDateFormat.LONG, + Locale.getDefault() + ).format(validUntil!!.time) + binding.achievementValidUntil.text = + getString(R.string.valid_until, untilText) + binding.achievementsReceivedButton.setOnClickListener { + val clipboard = + getSystemService(requireContext(), ClipboardManager::class.java) + val clip = ClipData.newPlainText(getString(R.string.voucher_code), code) + clipboard!!.setPrimaryClip(clip) + } + } + }, + { + // FIXME: Handle 204 - no content (when the last voucher just got assigned) + Toast.makeText( + context, + "Gutscheinanfrage fehlgeschlagen: ${it.message}", + Toast.LENGTH_LONG + ).show() + }) + } catch (e: Exception) { + Handler(Looper.getMainLooper()).post { + Toast.makeText( + context, + "Gutscheinanfrage nicht möglich", + Toast.LENGTH_LONG + ).show() + } + Log.e(TAG, "availableVouchers crashed", e) + } + } + override fun onSaveInstanceState(outState: Bundle) { super.onSaveInstanceState(outState) tracker?.onSaveInstanceState(outState) diff --git a/utils/src/main/kotlin/de/cyface/app/utils/trips/incentives/Incentives.kt b/utils/src/main/kotlin/de/cyface/app/utils/trips/incentives/Incentives.kt new file mode 100644 index 00000000..bd379eb0 --- /dev/null +++ b/utils/src/main/kotlin/de/cyface/app/utils/trips/incentives/Incentives.kt @@ -0,0 +1,196 @@ +/* + * Copyright 2023 Cyface GmbH + * + * This file is part of the Cyface App for Android. + * + * The Cyface App for Android 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. + * + * The Cyface App for Android 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 the Cyface App for Android. If not, see . + */ +package de.cyface.app.utils.trips.incentives + +import android.accounts.Account +import android.accounts.AccountManager +import android.accounts.AuthenticatorException +import android.accounts.NetworkErrorException +import android.content.Context +import android.os.Bundle +import android.util.Log +import com.android.volley.Response.ErrorListener +import com.android.volley.Response.Listener +import com.android.volley.toolbox.JsonObjectRequest +import com.android.volley.toolbox.Volley +import de.cyface.synchronization.Constants +import de.cyface.synchronization.CyfaceAuthenticator +import de.cyface.synchronization.SyncAdapter +import de.cyface.uploader.DefaultAuthenticator +import de.cyface.uploader.exception.SynchronizationInterruptedException +import de.cyface.utils.Validate +import org.json.JSONObject +import java.net.URL + + +/** + * The API to get the voucher data from. + * + * @author Armin Schnabel + * @version 1.0.0 + * @since 3.3.0 + * @property authenticator The authenticator to get the auth token from + * @property apiEndpoint An API endpoint running a Cyface Incentives API, like `https://some.url/api/v1` + */ +class Incentives(private val authenticator: CyfaceAuthenticator, private val apiEndpoint: String) { + + /** + * Requests the number of available vouchers. + * + * @param context the context required to load the account manager and to create cache dirs + * @param handler the handler which receives the response in case of success + * @param failureHandler the handler which receives the errors + */ + fun availableVouchers( + context: Context, + handler: Listener, + failureHandler: ErrorListener + ) { + // Acquire new auth token before each request (old one could be expired) + val jwtAuthToken = getAuthToken(authenticator, getAccount(context)) + + // Try to send the request and handle expected errors + val queue = Volley.newRequestQueue(context) + val url = voucherCountEndpoint().toString() + val request = object : JsonObjectRequest( + Method.GET, + url, + null, + handler, + failureHandler + ) { + override fun getHeaders(): MutableMap { + return headers(jwtAuthToken) + } + } + queue.add(request) + } + + /** + * Requests a voucher for the currently logged in user. + * + * @param context the context required to load the account manager and to create cache dirs + * @param handler the handler which receives the response in case of success + * @param failureHandler the handler which receives the errors + */ + fun voucher( + context: Context, + handler: Listener, + failureHandler: ErrorListener + ) { + // Acquire new auth token before each request (old one could be expired) + val jwtAuthToken = getAuthToken(authenticator, getAccount(context)) + + // Try to send the request and handle expected errors + val queue = Volley.newRequestQueue(context) + val url = voucherEndpoint().toString() + val request = object : JsonObjectRequest( + Method.GET, + url, + null, + handler, + failureHandler + ) { + override fun getHeaders(): MutableMap { + return headers(jwtAuthToken) + } + } + queue.add(request) + } + + private fun headers(jwtAuthToken: String): MutableMap { + val headers: MutableMap = HashMap() + headers["Authorization"] = "Bearer $jwtAuthToken" + return headers + } + + /** + * Returns the Cyface account. Throws a Runtime Exception if no or more than one account exists. + * + * @param context the Context to load the AccountManager + * @return the only existing account + */ + private fun getAccount(context: Context): Account { + val accountType = "de.cyface.app.r4r" + val accountManager = AccountManager.get(context) + val existingAccounts = accountManager.getAccountsByType(accountType) + Validate.isTrue(existingAccounts.size < 2, "More than one account exists.") + Validate.isTrue(existingAccounts.isNotEmpty(), "No account exists.") + return existingAccounts[0] + } + + /** + * Gets the authentication token from the [CyfaceAuthenticator]. + * + * @param authenticator The `CyfaceAuthenticator` to be used + * @param account The `Account` to get the token for + * @return The token as string + * @throws AuthenticatorException If no token was supplied which must be supported for implementing apps (SR) + * @throws NetworkErrorException If the network authentication request failed for any reasons + * @throws SynchronizationInterruptedException If the synchronization was [Thread.interrupted]. + */ + @Throws( + AuthenticatorException::class, + NetworkErrorException::class, + SynchronizationInterruptedException::class + ) + private fun getAuthToken(authenticator: CyfaceAuthenticator, account: Account): String { + val jwtAuthToken: String? + // Explicitly calling CyfaceAuthenticator.getAuthToken(), see its documentation + val bundle: Bundle? = try { + authenticator.getAuthToken(null, account, Constants.AUTH_TOKEN_TYPE, null) + } catch (e: NetworkErrorException) { + // This happened e.g. when Wifi was manually disabled just after synchronization started (Pixel 2 XL). + Log.w(SyncAdapter.TAG, "getAuthToken failed, was the connection closed? Aborting sync.") + throw e + } + if (bundle == null) { + // Because of Movebis we don't throw an IllegalStateException if there is no auth token + throw AuthenticatorException("No valid auth token supplied. Aborting data synchronization!") + } + jwtAuthToken = bundle.getString(AccountManager.KEY_AUTHTOKEN) + // When WifiSurveyor.deleteAccount() was called in the meantime the jwt token is empty, thus: + if (jwtAuthToken == null) { + Validate.isTrue(Thread.interrupted()) + throw SynchronizationInterruptedException("Sync interrupted, aborting sync.") + } + Log.d( + SyncAdapter.TAG, + "Login authToken: **" + jwtAuthToken.substring(jwtAuthToken.length - 7) + ) + return jwtAuthToken + } + + @Suppress("MemberVisibilityCanBePrivate") // Part of the API + private fun voucherCountEndpoint(): URL { + return URL(DefaultAuthenticator.returnUrlWithTrailingSlash(apiEndpoint) + "voucher_count") + } + + @Suppress("MemberVisibilityCanBePrivate") // Part of the API + private fun voucherEndpoint(): URL { + return URL(DefaultAuthenticator.returnUrlWithTrailingSlash(apiEndpoint) + "voucher") + } + + companion object { + /** + * The settings key used to identify the settings storing the URL of the server to get incentives from. + */ + const val INCENTIVES_ENDPOINT_URL_SETTINGS_KEY = "de.cyface.incentives.endpoint" + } +} \ No newline at end of file diff --git a/utils/src/main/res/drawable/baseline_verified_24.xml b/utils/src/main/res/drawable/baseline_verified_24.xml new file mode 100644 index 00000000..faafa977 --- /dev/null +++ b/utils/src/main/res/drawable/baseline_verified_24.xml @@ -0,0 +1,5 @@ + + + diff --git a/utils/src/main/res/drawable/baseline_workspace_premium_24.xml b/utils/src/main/res/drawable/baseline_workspace_premium_24.xml new file mode 100644 index 00000000..3f41bbe3 --- /dev/null +++ b/utils/src/main/res/drawable/baseline_workspace_premium_24.xml @@ -0,0 +1,5 @@ + + + diff --git a/utils/src/main/res/layout/fragment_trips.xml b/utils/src/main/res/layout/fragment_trips.xml index 53e7189a..292d2a8d 100644 --- a/utils/src/main/res/layout/fragment_trips.xml +++ b/utils/src/main/res/layout/fragment_trips.xml @@ -1,22 +1,182 @@ + + + + + + - - + android:layout_height="wrap_content" + android:orientation="vertical" + android:paddingBottom="16dp" + android:visibility="gone" + app:layout_constraintBottom_toBottomOf="parent" + app:layout_constraintLeft_toLeftOf="parent" + app:layout_constraintRight_toRightOf="parent" + app:layout_constraintTop_toBottomOf="@id/tripsList"> + + + + + + + + + + + + + + + + + + + + +