taler-android

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

commit 893a0ad9e204b1ae54b0ad0d74f3dd94b1cd2af6
parent f5d9e93e6d643fbd413c58da24cac4cbad4bf55b
Author: Iván Ávalos <avalos@disroot.org>
Date:   Wed,  2 Oct 2024 15:02:56 +0200

[wallet] ToS management features and extend review coverage to more flows

bug 0009235

Diffstat:
Mwallet/src/main/java/net/taler/wallet/exchanges/ExchangeAdapter.kt | 25+++++++++++++++++++++++++
Mwallet/src/main/java/net/taler/wallet/exchanges/ExchangeListFragment.kt | 26+++++++++++++++++++++++++-
Mwallet/src/main/java/net/taler/wallet/exchanges/ExchangeManager.kt | 20++++++++++++++++++++
Mwallet/src/main/java/net/taler/wallet/peer/IncomingPushPaymentFragment.kt | 2+-
Mwallet/src/main/java/net/taler/wallet/peer/OutgoingPullComposable.kt | 34++++++++++++++++++++++------------
Mwallet/src/main/java/net/taler/wallet/peer/OutgoingPullFragment.kt | 18+++++++++++++++---
Mwallet/src/main/java/net/taler/wallet/peer/OutgoingPushComposable.kt | 3++-
Mwallet/src/main/java/net/taler/wallet/peer/OutgoingState.kt | 17++++++++++++-----
Mwallet/src/main/java/net/taler/wallet/peer/PeerManager.kt | 43+++++++++++++++++++++++++++++++++++--------
Mwallet/src/main/java/net/taler/wallet/withdraw/PromptWithdrawFragment.kt | 2+-
Mwallet/src/main/java/net/taler/wallet/withdraw/ReviewExchangeTosFragment.kt | 4++++
Mwallet/src/main/res/menu/exchange.xml | 9+++++++++
Mwallet/src/main/res/navigation/nav_graph.xml | 10++++------
Mwallet/src/main/res/values/strings.xml | 2++
14 files changed, 177 insertions(+), 38 deletions(-)

diff --git a/wallet/src/main/java/net/taler/wallet/exchanges/ExchangeAdapter.kt b/wallet/src/main/java/net/taler/wallet/exchanges/ExchangeAdapter.kt @@ -36,11 +36,15 @@ interface ExchangeClickListener { fun onPeerReceive(item: ExchangeItem) fun onExchangeReload(item: ExchangeItem) fun onExchangeDelete(item: ExchangeItem) + fun onExchangeTosAccept(item: ExchangeItem) + fun onExchangeTosForget(item: ExchangeItem) + fun onExchangeTosView(item: ExchangeItem) } internal class ExchangeAdapter( private val selectOnly: Boolean, private val listener: ExchangeClickListener, + private val devMode: Boolean, ) : Adapter<ExchangeItemViewHolder>() { private var items = emptyList<ExchangeItem>() @@ -95,6 +99,15 @@ internal class ExchangeAdapter( private fun openMenu(anchor: View, item: ExchangeItem) = PopupMenu(context, anchor).apply { inflate(R.menu.exchange) + if (item.tosStatus == ExchangeTosStatus.Accepted) { + menu.findItem(R.id.action_view_tos).isVisible = true + menu.findItem(R.id.action_accept_tos).isVisible = false + menu.findItem(R.id.action_forget_tos).isVisible = devMode + } else { + menu.findItem(R.id.action_view_tos).isVisible = false + menu.findItem(R.id.action_accept_tos).isVisible = true + menu.findItem(R.id.action_forget_tos).isVisible = false + } setOnMenuItemClickListener { menuItem -> when (menuItem.itemId) { R.id.action_manual_withdrawal -> { @@ -109,6 +122,18 @@ internal class ExchangeAdapter( listener.onExchangeReload(item) true } + R.id.action_view_tos -> { + listener.onExchangeTosView(item) + true + } + R.id.action_accept_tos -> { + listener.onExchangeTosAccept(item) + true + } + R.id.action_forget_tos -> { + listener.onExchangeTosForget(item) + true + } R.id.action_delete -> { listener.onExchangeDelete(item) true diff --git a/wallet/src/main/java/net/taler/wallet/exchanges/ExchangeListFragment.kt b/wallet/src/main/java/net/taler/wallet/exchanges/ExchangeListFragment.kt @@ -25,14 +25,17 @@ import android.view.View import android.view.ViewGroup import android.widget.Toast import android.widget.Toast.LENGTH_LONG +import androidx.core.os.bundleOf import androidx.core.view.MenuProvider import androidx.fragment.app.Fragment import androidx.fragment.app.activityViewModels import androidx.lifecycle.Lifecycle.State.RESUMED +import androidx.lifecycle.lifecycleScope import androidx.navigation.fragment.findNavController import androidx.recyclerview.widget.DividerItemDecoration import androidx.recyclerview.widget.LinearLayoutManager.VERTICAL import com.google.android.material.dialog.MaterialAlertDialogBuilder +import kotlinx.coroutines.launch import net.taler.common.EventObserver import net.taler.common.fadeIn import net.taler.common.fadeOut @@ -50,7 +53,7 @@ open class ExchangeListFragment : Fragment(), ExchangeClickListener { protected lateinit var ui: FragmentExchangeListBinding protected open val isSelectOnly = false - private val exchangeAdapter by lazy { ExchangeAdapter(isSelectOnly, this) } + private val exchangeAdapter by lazy { ExchangeAdapter(isSelectOnly, this, model.devMode.value == true) } override fun onCreateView( inflater: LayoutInflater, @@ -177,4 +180,25 @@ open class ExchangeListFragment : Fragment(), ExchangeClickListener { .setPositiveButton(R.string.cancel) { _, _ -> } .show() } + + override fun onExchangeTosView(item: ExchangeItem) { + val bundle = bundleOf( + "exchangeBaseUrl" to item.exchangeBaseUrl, + "readOnly" to true, + ) + findNavController().navigate(R.id.action_global_reviewExchangeTos, bundle) + } + + override fun onExchangeTosAccept(item: ExchangeItem) { + val bundle = bundleOf("exchangeBaseUrl" to item.exchangeBaseUrl) + findNavController().navigate(R.id.action_global_reviewExchangeTos, bundle) + } + + override fun onExchangeTosForget(item: ExchangeItem) { + viewLifecycleOwner.lifecycleScope.launch { + exchangeManager.getExchangeTos(item.exchangeBaseUrl)?.let { tos -> + exchangeManager.forgetCurrentTos(item.exchangeBaseUrl, tos.currentEtag) + } + } + } } diff --git a/wallet/src/main/java/net/taler/wallet/exchanges/ExchangeManager.kt b/wallet/src/main/java/net/taler/wallet/exchanges/ExchangeManager.kt @@ -192,6 +192,26 @@ class ExchangeManager( return success } + /** + * Un-accept the terms of service of an exchange + */ + suspend fun forgetCurrentTos( + exchangeBaseUrl: String, + currentEtag: String, + ): Boolean { + var success = false + api.request<Unit>("setExchangeTosForgotten") { + put("exchangeBaseUrl", exchangeBaseUrl) + put("etag", currentEtag) + }.onError { error -> + Log.d(TAG, "Error setExchangeTosForgotten: $error") + }.onSuccess { + success = true + list() + } + return success + } + fun addDevExchanges() { scope.launch { listOf( diff --git a/wallet/src/main/java/net/taler/wallet/peer/IncomingPushPaymentFragment.kt b/wallet/src/main/java/net/taler/wallet/peer/IncomingPushPaymentFragment.kt @@ -55,7 +55,7 @@ class IncomingPushPaymentFragment : Fragment() { IncomingComposable(state, incomingPush) { terms -> if (terms is IncomingTosReview) { val args = bundleOf("exchangeBaseUrl" to terms.exchangeBaseUrl) - findNavController().navigate(R.id.action_promptPushPayment_to_reviewExchangeTOS, args) + findNavController().navigate(R.id.action_global_reviewExchangeTos, args) } else { peerManager.confirmPeerPushCredit(terms) } diff --git a/wallet/src/main/java/net/taler/wallet/peer/OutgoingPullComposable.kt b/wallet/src/main/java/net/taler/wallet/peer/OutgoingPullComposable.kt @@ -55,7 +55,6 @@ import net.taler.wallet.backend.TalerErrorCode import net.taler.wallet.backend.TalerErrorInfo import net.taler.wallet.cleanExchange import net.taler.wallet.compose.TalerSurface -import net.taler.wallet.exchanges.ExchangeItem import net.taler.wallet.exchanges.ExchangeTosStatus import net.taler.wallet.transactions.AmountType import net.taler.wallet.transactions.TransactionAmountComposable @@ -66,7 +65,8 @@ import kotlin.random.Random fun OutgoingPullComposable( amount: Amount, state: OutgoingState, - onCreateInvoice: (amount: Amount, subject: String, hours: Long, exchange: ExchangeItem) -> Unit, + onCreateInvoice: (amount: Amount, subject: String, hours: Long, exchangeBaseUrl: String) -> Unit, + onTosAccept: (exchangeBaseUrl: String) -> Unit, onClose: () -> Unit, ) { when(state) { @@ -75,6 +75,7 @@ fun OutgoingPullComposable( amount = amount, state = state, onCreateInvoice = onCreateInvoice, + onTosAccept = onTosAccept, ) is OutgoingError -> PeerErrorComposable(state, onClose) } @@ -98,7 +99,8 @@ fun PeerCreatingComposable() { fun OutgoingPullIntroComposable( amount: Amount, state: OutgoingState, - onCreateInvoice: (amount: Amount, subject: String, hours: Long, exchange: ExchangeItem) -> Unit, + onCreateInvoice: (amount: Amount, subject: String, hours: Long, exchangeBaseUrl: String) -> Unit, + onTosAccept: (exchangeBaseUrl: String) -> Unit, ) { val scrollState = rememberScrollState() Column( @@ -160,10 +162,10 @@ fun OutgoingPullIntroComposable( ) } - val exchangeItem = (state as? OutgoingChecked)?.exchangeItem + val exchangeBaseUrl = (state as? OutgoingChecked)?.exchangeBaseUrl TransactionInfoComposable( label = stringResource(id = R.string.withdraw_exchange), - info = if (exchangeItem == null) "" else cleanExchange(exchangeItem.exchangeBaseUrl), + info = if (exchangeBaseUrl == null) "" else cleanExchange(exchangeBaseUrl), ) Text( @@ -183,17 +185,22 @@ fun OutgoingPullIntroComposable( Button( modifier = Modifier.padding(16.dp), - enabled = subject.isNotBlank() && state is OutgoingChecked, + enabled = subject.isNotBlank() && (state is OutgoingChecked), onClick = { - onCreateInvoice( + val ex = exchangeBaseUrl ?: error("clickable without exchange") + if (state.tosStatus == ExchangeTosStatus.Accepted) onCreateInvoice( amount, subject, hours, - exchangeItem ?: error("clickable without exchange") - ) + ex + ) else onTosAccept(ex) }, ) { - Text(text = stringResource(R.string.receive_peer_create_button)) + if (state is OutgoingChecked && state.tosStatus != ExchangeTosStatus.Accepted) { + Text(text = stringResource(R.string.exchange_tos_accept)) + } else { + Text(text = stringResource(R.string.receive_peer_create_button)) + } } } } @@ -234,6 +241,7 @@ fun PeerPullComposableCreatingPreview() { amount = Amount.fromString("TESTKUDOS", "42.23"), state = OutgoingCreating, onCreateInvoice = { _, _, _, _ -> }, + onTosAccept = {}, onClose = {}, ) } @@ -247,6 +255,7 @@ fun PeerPullComposableCheckingPreview() { amount = Amount.fromString("TESTKUDOS", "42.23"), state = if (Random.nextBoolean()) OutgoingIntro else OutgoingChecking, onCreateInvoice = { _, _, _, _ -> }, + onTosAccept = {}, onClose = {}, ) } @@ -258,11 +267,11 @@ fun PeerPullComposableCheckedPreview() { TalerSurface { val amountRaw = Amount.fromString("TESTKUDOS", "42.42") val amountEffective = Amount.fromString("TESTKUDOS", "42.23") - val exchangeItem = ExchangeItem("https://example.org", "TESTKUDOS", emptyList(), null, ExchangeTosStatus.Accepted) OutgoingPullComposable( amount = Amount.fromString("TESTKUDOS", "42.23"), - state = OutgoingChecked(amountRaw, amountEffective, exchangeItem), + state = OutgoingChecked(amountRaw, amountEffective, "https://exchange.demo.taler.net/", ExchangeTosStatus.Accepted), onCreateInvoice = { _, _, _, _ -> }, + onTosAccept = {}, onClose = {}, ) } @@ -278,6 +287,7 @@ fun PeerPullComposableErrorPreview() { amount = Amount.fromString("TESTKUDOS", "42.23"), state = state, onCreateInvoice = { _, _, _, _ -> }, + onTosAccept = {}, onClose = {}, ) } diff --git a/wallet/src/main/java/net/taler/wallet/peer/OutgoingPullFragment.kt b/wallet/src/main/java/net/taler/wallet/peer/OutgoingPullFragment.kt @@ -21,6 +21,7 @@ import android.view.LayoutInflater import android.view.View import android.view.ViewGroup import androidx.compose.ui.platform.ComposeView +import androidx.core.os.bundleOf import androidx.fragment.app.Fragment import androidx.fragment.app.activityViewModels import androidx.lifecycle.Lifecycle @@ -33,13 +34,13 @@ import net.taler.wallet.MainViewModel import net.taler.wallet.R import net.taler.wallet.compose.TalerSurface import net.taler.wallet.compose.collectAsStateLifecycleAware -import net.taler.wallet.exchanges.ExchangeItem import net.taler.wallet.showError class OutgoingPullFragment : Fragment() { private val model: MainViewModel by activityViewModels() private val peerManager get() = model.peerManager private val transactionManager get() = model.transactionManager + private val exchangeManager get() = model.exchangeManager private val balanceManager get() = model.balanceManager override fun onCreateView( @@ -61,6 +62,7 @@ class OutgoingPullFragment : Fragment() { amount = amount.withSpec(spec), state = state, onCreateInvoice = this@OutgoingPullFragment::onCreateInvoice, + onTosAccept = this@OutgoingPullFragment::onTosAccept, onClose = { findNavController().navigate(R.id.action_nav_peer_pull_to_nav_main) } @@ -89,6 +91,11 @@ class OutgoingPullFragment : Fragment() { } } } + + exchangeManager.exchanges.observe(viewLifecycleOwner) { exchanges -> + // detect ToS acceptation + peerManager.refreshPeerPullCreditTos(exchanges) + } } override fun onStart() { @@ -101,7 +108,12 @@ class OutgoingPullFragment : Fragment() { if (!requireActivity().isChangingConfigurations) peerManager.resetPullPayment() } - private fun onCreateInvoice(amount: Amount, summary: String, hours: Long, exchange: ExchangeItem) { - peerManager.initiatePeerPullCredit(amount, summary, hours, exchange) + private fun onTosAccept(exchangeBaseUrl: String) { + val bundle = bundleOf("exchangeBaseUrl" to exchangeBaseUrl) + findNavController().navigate(R.id.action_global_reviewExchangeTos, bundle) + } + + private fun onCreateInvoice(amount: Amount, summary: String, hours: Long, exchangeBaseUrl: String) { + peerManager.initiatePeerPullCredit(amount, summary, hours, exchangeBaseUrl) } } diff --git a/wallet/src/main/java/net/taler/wallet/peer/OutgoingPushComposable.kt b/wallet/src/main/java/net/taler/wallet/peer/OutgoingPushComposable.kt @@ -48,6 +48,7 @@ import net.taler.wallet.R import net.taler.wallet.backend.TalerErrorCode import net.taler.wallet.backend.TalerErrorInfo import net.taler.wallet.compose.TalerSurface +import net.taler.wallet.exchanges.ExchangeTosStatus import kotlin.random.Random @Composable @@ -192,7 +193,7 @@ fun PeerPushComposableCheckedPreview() { TalerSurface { val amountEffective = Amount.fromString("TESTKUDOS", "42.42") val amountRaw = Amount.fromString("TESTKUDOS", "42.23") - val state = OutgoingChecked(amountRaw, amountEffective) + val state = OutgoingChecked(amountRaw, amountEffective, "https://exchange.demo.taler.net", ExchangeTosStatus.Accepted) OutgoingPushComposable( state = state, amount = amountEffective, diff --git a/wallet/src/main/java/net/taler/wallet/peer/OutgoingState.kt b/wallet/src/main/java/net/taler/wallet/peer/OutgoingState.kt @@ -19,17 +19,23 @@ package net.taler.wallet.peer import kotlinx.serialization.Serializable import net.taler.common.Amount import net.taler.wallet.backend.TalerErrorInfo -import net.taler.wallet.exchanges.ExchangeItem +import net.taler.wallet.exchanges.ExchangeTosStatus sealed class OutgoingState -object OutgoingIntro : OutgoingState() -object OutgoingChecking : OutgoingState() + +data object OutgoingIntro : OutgoingState() + +data object OutgoingChecking : OutgoingState() + data class OutgoingChecked( val amountRaw: Amount, val amountEffective: Amount, - val exchangeItem: ExchangeItem? = null, + val exchangeBaseUrl: String, + val tosStatus: ExchangeTosStatus?, ) : OutgoingState() -object OutgoingCreating : OutgoingState() + +data object OutgoingCreating : OutgoingState() + data class OutgoingResponse( val transactionId: String, ) : OutgoingState() @@ -54,6 +60,7 @@ data class InitiatePeerPullPaymentResponse( data class CheckPeerPushDebitResponse( val amountRaw: Amount, val amountEffective: Amount, + val exchangeBaseUrl: String, ) @Serializable diff --git a/wallet/src/main/java/net/taler/wallet/peer/PeerManager.kt b/wallet/src/main/java/net/taler/wallet/peer/PeerManager.kt @@ -94,7 +94,8 @@ class PeerManager( _outgoingPullState.value = OutgoingChecked( amountRaw = it.amountRaw, amountEffective = it.amountEffective, - exchangeItem = exchangeItem, + exchangeBaseUrl = exchangeItem.exchangeBaseUrl, + tosStatus = exchangeItem.tosStatus, ) }.onError { error -> Log.e(TAG, "got checkPeerPullCredit error result $error") @@ -103,12 +104,12 @@ class PeerManager( } } - fun initiatePeerPullCredit(amount: Amount, summary: String, expirationHours: Long, exchange: ExchangeItem) { + fun initiatePeerPullCredit(amount: Amount, summary: String, expirationHours: Long, exchangeBaseUrl: String) { _outgoingPullState.value = OutgoingCreating scope.launch(Dispatchers.IO) { val expiry = Timestamp.fromMillis(System.currentTimeMillis() + HOURS.toMillis(expirationHours)) api.request("initiatePeerPullCredit", InitiatePeerPullPaymentResponse.serializer()) { - put("exchangeBaseUrl", exchange.exchangeBaseUrl) + put("exchangeBaseUrl", exchangeBaseUrl) put("partialContractTerms", JSONObject().apply { put("amount", amount.toJSONString()) put("summary", summary) @@ -133,11 +134,15 @@ class PeerManager( api.request("checkPeerPushDebit", CheckPeerPushDebitResponse.serializer()) { put("amount", amount.toJSONString()) }.onSuccess { response -> - _outgoingPushState.value = OutgoingChecked( - amountRaw = response.amountRaw, - amountEffective = response.amountEffective, - // FIXME add exchangeItem once available in API - ) + scope.launch { + val exchangeItem = exchangeManager.findExchangeByUrl(response.exchangeBaseUrl) + _outgoingPushState.value = OutgoingChecked( + amountRaw = response.amountRaw, + amountEffective = response.amountEffective, + exchangeBaseUrl = response.exchangeBaseUrl, + tosStatus = exchangeItem?.tosStatus, + ) + } }.onError { error -> Log.e(TAG, "got checkPeerPushDebit error result $error") _outgoingPushState.value = OutgoingError(error) @@ -314,4 +319,26 @@ class PeerManager( newState } } + + @UiThread + fun refreshPeerPullCreditTos(exchanges: List<ExchangeItem>) = scope.launch { + _outgoingPullState.update { state -> + var newState = state + if (state is OutgoingChecked) { + exchanges.find { it.exchangeBaseUrl == state.exchangeBaseUrl }?.let { exchange -> + if (exchange.tosStatus == ExchangeTosStatus.Accepted) { + newState = OutgoingChecked( + amountRaw = state.amountRaw, + amountEffective = state.amountEffective, + exchangeBaseUrl = state.exchangeBaseUrl, + tosStatus = exchange.tosStatus, + ) + } + } ?: run { + Log.d(TAG, "could not refresh ToS status, exchange ${state.exchangeBaseUrl} was not found") + } + } + newState + } + } } diff --git a/wallet/src/main/java/net/taler/wallet/withdraw/PromptWithdrawFragment.kt b/wallet/src/main/java/net/taler/wallet/withdraw/PromptWithdrawFragment.kt @@ -150,7 +150,7 @@ class PromptWithdrawFragment: Fragment() { onTosReview = { // TODO: rewrite ToS review screen in compose val args = bundleOf("exchangeBaseUrl" to s.exchangeBaseUrl) - findNavController().navigate(R.id.action_promptWithdraw_to_reviewExchangeTOS, args) + findNavController().navigate(R.id.action_global_reviewExchangeTos, args) }, onConfirm = { age -> withdrawManager.acceptWithdrawal(age) diff --git a/wallet/src/main/java/net/taler/wallet/withdraw/ReviewExchangeTosFragment.kt b/wallet/src/main/java/net/taler/wallet/withdraw/ReviewExchangeTosFragment.kt @@ -19,6 +19,8 @@ package net.taler.wallet.withdraw import android.os.Bundle import android.view.LayoutInflater import android.view.View +import android.view.View.GONE +import android.view.View.VISIBLE import android.view.ViewGroup import androidx.fragment.app.Fragment import androidx.fragment.app.activityViewModels @@ -60,7 +62,9 @@ class ReviewExchangeTosFragment : Fragment() { val exchangeBaseUrl = arguments?.getString("exchangeBaseUrl") ?: error("no exchangeBaseUrl passed") + val readOnly = arguments?.getBoolean("readOnly") ?: false + ui.buttonCard.visibility = if (readOnly) GONE else VISIBLE ui.acceptTosCheckBox.isChecked = false ui.acceptTosCheckBox.setOnCheckedChangeListener { _, _ -> tos?.let { diff --git a/wallet/src/main/res/menu/exchange.xml b/wallet/src/main/res/menu/exchange.xml @@ -25,6 +25,15 @@ android:id="@+id/action_reload" android:title="@string/exchange_reload" /> <item + android:id="@+id/action_view_tos" + android:title="@string/exchange_tos_view" /> + <item + android:id="@+id/action_accept_tos" + android:title="@string/exchange_tos_accept" /> + <item + android:id="@+id/action_forget_tos" + android:title="@string/exchange_tos_forget" /> + <item android:id="@+id/action_delete" android:title="@string/transactions_delete" /> </menu> diff --git a/wallet/src/main/res/navigation/nav_graph.xml b/wallet/src/main/res/navigation/nav_graph.xml @@ -263,9 +263,6 @@ android:id="@+id/action_promptPushPayment_to_nav_main" app:destination="@id/nav_main" app:popUpTo="@id/nav_main" /> - <action - android:id="@+id/action_promptPushPayment_to_reviewExchangeTOS" - app:destination="@id/reviewExchangeTOS" /> </fragment> <fragment @@ -340,9 +337,6 @@ android:name="net.taler.wallet.withdraw.PromptWithdrawFragment" android:label="@string/nav_prompt_withdraw"> <action - android:id="@+id/action_promptWithdraw_to_reviewExchangeTOS" - app:destination="@id/reviewExchangeTOS" /> - <action android:id="@+id/action_promptWithdraw_to_nav_main" app:destination="@id/nav_main" app:popUpTo="@id/nav_main" /> @@ -428,4 +422,8 @@ android:id="@+id/action_nav_payto_uri" app:destination="@id/nav_payto_uri" /> + <action + android:id="@+id/action_global_reviewExchangeTos" + app:destination="@id/reviewExchangeTOS" /> + </navigation> diff --git a/wallet/src/main/res/values/strings.xml b/wallet/src/main/res/values/strings.xml @@ -309,6 +309,8 @@ GNU Taler is immune against many types of fraud, such as phishing of credit card <string name="exchange_settings_summary">Manage list of providers known to this wallet</string> <string name="exchange_settings_title">Providers</string> <string name="exchange_tos_accept">Accept Terms of Service</string> + <string name="exchange_tos_forget">Reject Terms of Service</string> + <string name="exchange_tos_view">View Terms of Service</string> <string name="exchange_tos_error">Error showing Terms of Service: %1$s</string> <!-- Losses -->