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:
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>