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">
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/utils/src/main/res/values-de/strings.xml b/utils/src/main/res/values-de/strings.xml
index 470d4674..c93c0394 100644
--- a/utils/src/main/res/values-de/strings.xml
+++ b/utils/src/main/res/values-de/strings.xml
@@ -109,6 +109,18 @@
Bitte wählen Sie einen Standort aus
Der Eintrag wurde erstellt und auf den Track verschoben
+
+ Noch %1$.0f von %2$.0f km bis zum Gutschein
+ nextbike Gutschein freigeschaltet
+ Gutschein anzeigen
+ Gutscheincode: %s
+ gültig bis: %s
+ Gutscheincode kopieren
+ Gutscheincode
+ %d x 15 Minuten nextbike Gutscheine übrig
+ 1x 15 Freiminuten auf die nächste Ausleihe
+ in Schkeuditz - nextbike Nordsachsen
+
Fahrtdetails
Höhe [m]
diff --git a/utils/src/main/res/values-it/strings.xml b/utils/src/main/res/values-it/strings.xml
index 5ec4632b..1153c1fa 100644
--- a/utils/src/main/res/values-it/strings.xml
+++ b/utils/src/main/res/values-it/strings.xml
@@ -109,6 +109,18 @@
Selezionare una località
L\'elemento è stato creato e spostato in pista
+
+ %1$.0f of %2$.0f km to unlock the voucher
+ nextbike voucher unlocked
+ show voucher
+ voucher code: %s
+ valid until: %s
+ copy voucher code
+ voucher code
+ %d x 15 minutes nextbike vouchers left
+ 1x 15 free minutes on the next rental
+ in Schkeuditz - nextbike Nordsachsen
+
Trip details
elevation [m]
diff --git a/utils/src/main/res/values/strings.xml b/utils/src/main/res/values/strings.xml
index 6d058882..73a591c1 100644
--- a/utils/src/main/res/values/strings.xml
+++ b/utils/src/main/res/values/strings.xml
@@ -112,6 +112,18 @@
Please select a location
The item was created and moved onto the track
+
+ %1$.0f of %2$.0f km to unlock the voucher
+ nextbike voucher unlocked
+ show voucher
+ voucher code: %s
+ valid until: %s
+ copy voucher code
+ voucher code
+ %d x 15 minutes nextbike vouchers left
+ 1x 15 free minutes on the next rental
+ in Schkeuditz - nextbike Nordsachsen
+
Trip details
elevation [m]