taler-android

Android apps for GNU Taler (wallet, PoS, cashier)
Log | Files | Refs | README | LICENSE

commit 6a69c56d41f397fe21ef6e348af61fd2c381ed58
parent 4ec90bff4715f4961ce3c60140e6d3344bdfd3e4
Author: Bohdan Potuzhnyi <bohdan.potuzhnyi@gmail.com>
Date:   Thu,  1 May 2025 23:53:19 +0200

[pos] changing the previous login method to support tokens

Diffstat:
Mmerchant-terminal/src/main/java/net/taler/merchantpos/config/ConfigFragment.kt | 130++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-----------------
Mmerchant-terminal/src/main/java/net/taler/merchantpos/config/ConfigManager.kt | 170+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mmerchant-terminal/src/main/res/layout/fragment_merchant_config.xml | 88+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++----
Mmerchant-terminal/src/main/res/values/strings.xml | 11+++++++++++
4 files changed, 367 insertions(+), 32 deletions(-)

diff --git a/merchant-terminal/src/main/java/net/taler/merchantpos/config/ConfigFragment.kt b/merchant-terminal/src/main/java/net/taler/merchantpos/config/ConfigFragment.kt @@ -39,9 +39,14 @@ import androidx.core.content.ContextCompat import androidx.core.net.toUri import androidx.fragment.app.Fragment import androidx.fragment.app.activityViewModels +import androidx.lifecycle.lifecycleScope import com.google.android.material.button.MaterialButtonToggleGroup import com.google.android.material.snackbar.BaseTransientBottomBar.LENGTH_LONG import com.google.android.material.snackbar.Snackbar +import com.google.android.material.textfield.TextInputEditText +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext import net.taler.common.navigate import net.taler.merchantpos.MainViewModel import net.taler.merchantpos.R @@ -68,8 +73,8 @@ class ConfigFragment : Fragment() { private val scanner by lazy { BarcodeScanning.getClient( BarcodeScannerOptions.Builder() - .setBarcodeFormats(Barcode.FORMAT_QR_CODE) - .build() + .setBarcodeFormats(Barcode.FORMAT_QR_CODE) + .build() ) } @@ -100,35 +105,84 @@ class ConfigFragment : Fragment() { } } -// configManager.configUpdateResult -// .observe(viewLifecycleOwner) { result -> -// if (result != null && onConfigUpdate(result)) { -// // one‐shot observer -// configManager.configUpdateResult.removeObservers(viewLifecycleOwner) -// } -// } + ui.timeOptionGroup.setOnCheckedChangeListener { _, checkedId -> + when (checkedId) { + R.id.foreverOption -> { + ui.customDurationLayout.visibility = GONE + } + R.id.customOption -> { + ui.customDurationLayout.visibility = VISIBLE + } + } + } + + + // 1) Extract base URL and username if pasted with /instances/username + // Only parse URL when user finishes editing (focus lost) + ui.merchantUrlView.editText!!.setOnFocusChangeListener { v, hasFocus -> + if (!hasFocus) { + parseMerchantUrlAndUpdateFields() + } + } // manual configuration OK button ui.okNewButton.setOnClickListener { - val inputUrl = ui.merchantUrlView.editText!!.text.toString() - val url = if (inputUrl.startsWith("http")) { - inputUrl - } else { - "https://$inputUrl".also { ui.merchantUrlView.editText!!.setText(it) } - } + // launch coroutine to fetch limited token before config update + lifecycleScope.launch { + // prepare UI + ui.progressBarNew.visibility = VISIBLE + ui.okNewButton.visibility = INVISIBLE + + // normalize URL + val inputUrl = ui.merchantUrlView.editText!!.text.toString() + val url = if (inputUrl.startsWith("http")) inputUrl else "https://$inputUrl" + + // retrieve username (may have been set by listener) + val username = ui.usernameView.editText!!.text.toString().trim() + // initial secret/token from user + val initialSecret = ui.tokenView.editText!!.text.toString().trim() + + val duration : TokenDuration = if (ui.foreverOption.isChecked) { + TokenDuration.Forever + } else { + val value = ui.durationValueInput.text.toString().toLongOrNull() + ?: throw IllegalArgumentException("Please enter a number") + val unit = ui.durationUnitSpinner.selectedItem.toString() + // convert to microseconds + val factor = when (unit) { + "seconds" -> 1_000_000L + "minutes" -> 60 * 1_000_000L + "hours" -> 60 * 60 * 1_000_000L + "days" -> 24 * 60 * 60 * 1_000_000L + else -> 1_000_000L + } + TokenDuration.Micros(value * factor) + } - ui.progressBarNew.visibility = VISIBLE - ui.okNewButton.visibility = INVISIBLE - val config = Config.New( - merchantUrl = url, - accessToken = ui.tokenView.editText!!.text.toString(), - savePassword = ui.saveTokenCheckBox.isChecked, - ) + // fetch limited write token + val limitedToken = try { + withContext(Dispatchers.IO) { + configManager.fetchLimitedAccessToken(url, username, initialSecret, duration) + } + } catch (e: Exception) { + ui.progressBarNew.visibility = INVISIBLE + ui.okNewButton.visibility = VISIBLE + Log.e("ConfigFragment", "Error fetching limited token: ${e.message}") + Snackbar.make(requireView(), getString(R.string.config_error_network), LENGTH_LONG).show() + return@launch + } - configManager.fetchConfig(config, true) - configManager.configUpdateResult.observe(viewLifecycleOwner) { result -> - if (onConfigUpdate(result)) { - configManager.configUpdateResult.removeObservers(viewLifecycleOwner) + // proceed with normal config fetch using limited token + val config = Config.New( + merchantUrl = url, + accessToken = limitedToken, + savePassword = ui.saveTokenCheckBox.isChecked + ) + configManager.fetchConfig(config, true) + configManager.configUpdateResult.observe(viewLifecycleOwner) { result -> + if (onConfigUpdate(result)) { + configManager.configUpdateResult.removeObservers(viewLifecycleOwner) + } } } } @@ -170,12 +224,13 @@ class ConfigFragment : Fragment() { private fun updateView(isInitialization: Boolean = false) { if (isInitialization) { - ui.merchantUrlView.editText!!.setText(NEW_CONFIG_URL_DEMO) + when (val cfg = configManager.config) { is Config.New -> { if (cfg.merchantUrl.isNotBlank()) { ui.merchantUrlView.editText!!.setText(cfg.merchantUrl) + parseMerchantUrlAndUpdateFields() } ui.saveTokenCheckBox.isChecked = cfg.savePassword } @@ -219,6 +274,25 @@ class ConfigFragment : Fragment() { ui.okNewButton.visibility = VISIBLE } + private fun parseMerchantUrlAndUpdateFields() { + val input = ui.merchantUrlView.editText!!.text.toString().trim() + val uri = input.toUri() + // Build base URL: scheme://host[:port] + val scheme = uri.scheme ?: "" + val host = uri.host ?: "" + val port = if (uri.port != -1) ":${uri.port}" else "" + val baseUrl = "$scheme://$host$port" + // Check for /instances/username + val segments = uri.pathSegments + if (segments.size >= 2 && segments[0].equals("instances", true)) { + //Ensure that the username has been transferred to the username field + ui.usernameView.editText!!.setText(segments[1]) + } + // Ensure merchant URL has only the base + ui.merchantUrlView.editText!!.setText(baseUrl) + } + + // ─── CameraX integration ─────────────────────────────────────────── // 1) permission launcher @@ -288,7 +362,7 @@ class ConfigFragment : Fragment() { (requireActivity() as MainActivity).handleSetupIntent(intent) // show loader until ConfigFetcherFragment takes over - ui.progressBarQr.visibility = View.VISIBLE + ui.progressBarQr.visibility = VISIBLE ui.previewView.visibility = View.INVISIBLE } diff --git a/merchant-terminal/src/main/java/net/taler/merchantpos/config/ConfigManager.kt b/merchant-terminal/src/main/java/net/taler/merchantpos/config/ConfigManager.kt @@ -44,6 +44,28 @@ import net.taler.merchantlib.MerchantConfig import net.taler.merchantpos.BuildConfig import net.taler.merchantpos.R import androidx.core.net.toUri +import io.ktor.client.request.post +import io.ktor.client.request.setBody +import io.ktor.http.ContentType +import io.ktor.http.HttpHeaders +import io.ktor.http.contentType +import kotlinx.serialization.KSerializer +import kotlinx.serialization.SerializationException +import kotlinx.serialization.descriptors.PrimitiveKind +import kotlinx.serialization.descriptors.PrimitiveSerialDescriptor +import kotlinx.serialization.descriptors.SerialDescriptor +import kotlinx.serialization.descriptors.buildClassSerialDescriptor +import kotlinx.serialization.descriptors.element +import kotlinx.serialization.encoding.Decoder +import kotlinx.serialization.encoding.Encoder +import kotlinx.serialization.json.JsonDecoder +import kotlinx.serialization.json.JsonElement +import kotlinx.serialization.json.JsonEncoder +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.JsonPrimitive +import kotlinx.serialization.json.buildJsonObject +import kotlinx.serialization.json.long +import kotlinx.serialization.json.longOrNull private const val SETTINGS_NAME = "taler-merchant-terminal" @@ -74,6 +96,122 @@ private val VERSION = Version.parse(BuildConfig.BACKEND_API_VERSION)!! private val TAG = ConfigManager::class.java.simpleName +/* -- Limited access token -- */ +@kotlinx.serialization.Serializable +private data class LimitedTokenResponse( + val token: String, + val scope: String, + val refreshable: Boolean, + val expiration: TokenExpiration +) + +@kotlinx.serialization.Serializable(with = TokenExpiration.Serializer::class) +sealed class TokenExpiration { + data class Seconds(val t_s: Long) : TokenExpiration() + object Never : TokenExpiration() + + object Serializer : KSerializer<TokenExpiration> { + override val descriptor: SerialDescriptor = + buildClassSerialDescriptor("TokenExpiration") { + element<JsonElement>("t_s") + } + + override fun serialize(encoder: Encoder, value: TokenExpiration) { + val jsonEncoder = encoder as? JsonEncoder + ?: throw SerializationException("TokenExpiration can be serialized only by JSON") + val obj = when (value) { + is Seconds -> + buildJsonObject { put("t_s", JsonPrimitive(value.t_s)) } + Never -> + buildJsonObject { put("t_s", JsonPrimitive("never")) } + } + jsonEncoder.encodeJsonElement(obj) + } + + override fun deserialize(decoder: Decoder): TokenExpiration { + val jsonDecoder = decoder as? JsonDecoder + ?: throw SerializationException("TokenExpiration can be deserialized only by JSON") + val element = jsonDecoder.decodeJsonElement() + if (element !is JsonObject) { + throw SerializationException("Expected JSON object for TokenExpiration, got: $element") + } + val field = element["t_s"] + ?: throw SerializationException("Missing 't_s' in TokenExpiration: $element") + + return when { + field is JsonPrimitive && field.longOrNull != null -> + Seconds(field.long) + field is JsonPrimitive && field.isString && field.content == "never" -> + Never + else -> + throw SerializationException("Invalid 't_s' value in TokenExpiration: $field") + } + } + } +} + +@kotlinx.serialization.Serializable +data class TokenRequest( + val scope: String, + val duration: TokenDuration +) + + +@kotlinx.serialization.Serializable(with = TokenDuration.Serializer::class) +sealed class TokenDuration { + object Forever : TokenDuration() + data class Micros(val us: Long) : TokenDuration() + + object Serializer : KSerializer<TokenDuration> { + // describe an object with a single property "d_us" + override val descriptor: SerialDescriptor = buildClassSerialDescriptor("TokenDuration") { + element<JsonElement>("d_us") + } + + override fun serialize(encoder: Encoder, value: TokenDuration) { + // we need a Json-specific encoder + val jsonEncoder = encoder as? JsonEncoder + ?: throw SerializationException("Can be serialized only by JSON") + // build the JSON object + val obj = when (value) { + is Forever -> buildJsonObject { + // for "forever", we still emit an object, + // here storing the literal string under "d_us" + put("d_us", JsonPrimitive("forever")) + } + is Micros -> buildJsonObject { + put("d_us", JsonPrimitive(value.us)) + } + } + jsonEncoder.encodeJsonElement(obj) + } + + override fun deserialize(decoder: Decoder): TokenDuration { + // we need a Json-specific decoder + val jsonDecoder = decoder as? JsonDecoder + ?: throw SerializationException("Can be deserialized only by JSON") + val element = jsonDecoder.decodeJsonElement() + if (element !is JsonObject) { + throw SerializationException("Expected JSON object for TokenDuration, got: $element") + } + // look up our single field + val field = element["d_us"] + ?: throw SerializationException("Missing 'd_us' field in $element") + return when { + field is JsonPrimitive && field.longOrNull != null -> + Micros(field.long) + field is JsonPrimitive && field.isString && field.content == "forever" -> + Forever + else -> + throw SerializationException("Invalid 'd_us' value: $field") + } + } + } +} + +/* -- Limited access token END -- */ + + interface ConfigurationReceiver { /** * Returns null if the configuration was valid, or a error string for user display otherwise. @@ -220,6 +358,38 @@ class ConfigManager( mConfigUpdateResult.postValue(ConfigUpdateResult.Success(configResponse.currency)) } + /** + * POSTs to /instances/{username}/private/token with the user’s raw secret, + * returns the new “write” token (without the “secret-token:” prefix). + */ + @WorkerThread + suspend fun fetchLimitedAccessToken( + baseUrl: String, + username: String, + initialSecret: String, + duration: TokenDuration + ): String { + val tokenUrl = baseUrl.toUri() + .buildUpon() + .appendPath("instances") + .appendPath(username) + .appendPath("private") + .appendPath("token") + .build() + .toString() + + val bearer = "Bearer secret-token:$initialSecret" + val resp: LimitedTokenResponse = httpClient + .post(tokenUrl) { + header(HttpHeaders.Authorization, bearer) + contentType(ContentType.Application.Json) + setBody(TokenRequest(scope = "write", duration = duration)) + } + .body() + + return resp.token.removePrefix("secret-token:") + } + @UiThread fun forgetPassword() { config = when (val c = config) { diff --git a/merchant-terminal/src/main/res/layout/fragment_merchant_config.xml b/merchant-terminal/src/main/res/layout/fragment_merchant_config.xml @@ -31,6 +31,8 @@ android:layout_width="match_parent" android:layout_height="wrap_content" app:singleSelection="true" + android:padding="4dp" + app:layout_constraintTop_toTopOf="parent" app:checkedButton="@id/newConfigButton"> <Button @@ -124,19 +126,38 @@ android:inputType="textUri" /> </com.google.android.material.textfield.TextInputLayout> + <!-- Adding Username-URL field --> + <com.google.android.material.textfield.TextInputLayout + android:id="@+id/usernameView" + android:layout_width="0dp" + android:layout_height="wrap_content" + android:layout_margin="16dp" + android:hint="@string/config_username" + app:boxBackgroundMode="outline" + app:boxBackgroundColor="@android:color/transparent" + app:layout_constraintStart_toStartOf="parent" + app:layout_constraintEnd_toEndOf="parent" + app:layout_constraintTop_toBottomOf="@id/merchantUrlView"> + + <com.google.android.material.textfield.TextInputEditText + android:layout_width="match_parent" + android:layout_height="wrap_content" + android:inputType="textUri" /> + </com.google.android.material.textfield.TextInputLayout> + <!-- Access-token field --> <com.google.android.material.textfield.TextInputLayout android:id="@+id/tokenView" android:layout_width="0dp" android:layout_height="wrap_content" android:layout_margin="16dp" - android:hint="@string/config_token" + android:hint="@string/config_password" app:boxBackgroundMode="outline" app:boxBackgroundColor="@android:color/transparent" app:endIconMode="password_toggle" app:layout_constraintStart_toStartOf="parent" app:layout_constraintEnd_toStartOf="@+id/forgetTokenButton" - app:layout_constraintTop_toBottomOf="@id/merchantUrlView"> + app:layout_constraintTop_toBottomOf="@id/usernameView"> <com.google.android.material.textfield.TextInputEditText android:layout_width="match_parent" @@ -156,6 +177,65 @@ app:layout_constraintBottom_toBottomOf="@id/tokenView" app:layout_constraintEnd_toEndOf="parent" /> + <!-- Relative Time Selection --> + <LinearLayout + android:id="@+id/relativeTimeGroup" + android:layout_width="0dp" + android:layout_height="wrap_content" + android:layout_margin="16dp" + android:orientation="vertical" + app:layout_constraintTop_toBottomOf="@id/tokenView" + app:layout_constraintStart_toStartOf="parent" + app:layout_constraintEnd_toEndOf="parent"> + + <RadioGroup + android:id="@+id/timeOptionGroup" + android:layout_width="match_parent" + android:layout_height="wrap_content" + android:orientation="horizontal"> + + <RadioButton + android:id="@+id/foreverOption" + android:layout_width="0dp" + android:layout_height="wrap_content" + android:layout_weight="1" + android:gravity="center" + android:checked="true" + android:text="@string/forever" /> + + <RadioButton + android:id="@+id/customOption" + android:layout_width="0dp" + android:layout_height="wrap_content" + android:layout_weight="1" + android:gravity="center" + android:text="@string/custom_duration" /> + </RadioGroup> + + <LinearLayout + android:id="@+id/customDurationLayout" + android:layout_width="match_parent" + android:layout_height="wrap_content" + android:orientation="horizontal" + android:visibility="gone" + android:layout_marginTop="8dp"> + + <EditText + android:id="@+id/durationValueInput" + android:layout_width="0dp" + android:layout_weight="1" + android:layout_height="wrap_content" + android:inputType="number" + android:hint="@string/duration" /> + + <Spinner + android:id="@+id/durationUnitSpinner" + android:layout_width="wrap_content" + android:layout_height="wrap_content" + android:entries="@array/time_units_array" /> + </LinearLayout> + </LinearLayout> + <!-- save-password checkbox --> <CheckBox android:id="@+id/saveTokenCheckBox" @@ -168,7 +248,7 @@ android:text="@string/config_save_password" app:layout_constraintStart_toStartOf="parent" app:layout_constraintEnd_toStartOf="@+id/okNewButton" - app:layout_constraintTop_toBottomOf="@id/tokenView" + app:layout_constraintTop_toBottomOf="@id/relativeTimeGroup" app:layout_constraintBottom_toBottomOf="parent" app:layout_constraintHorizontal_chainStyle="spread_inside" /> @@ -180,7 +260,7 @@ android:layout_margin="16dp" android:text="@string/config_ok" app:layout_constraintEnd_toEndOf="parent" - app:layout_constraintTop_toBottomOf="@id/tokenView" + app:layout_constraintTop_toBottomOf="@id/relativeTimeGroup" app:layout_constraintBottom_toBottomOf="parent" /> <!-- progress spinner --> diff --git a/merchant-terminal/src/main/res/values/strings.xml b/merchant-terminal/src/main/res/values/strings.xml @@ -88,5 +88,16 @@ <string name="host_apdu_service_desc">Taler Merchant NFC payments</string> <string name="config_qr_label">QR config</string> + <string name="scan_qr_hint">Scan the QR code from Merchant Backoffice</string> + <string name="forever">Forever</string> + <string name="custom_duration">Custom duration</string> + <string name="duration">Duration</string> + <string-array name="time_units_array"> + <item>Seconds</item> + <item>Minutes</item> + <item>Hours</item> + <item>Days</item> + </string-array> + </resources>