From 13f9dcfa0c24abe1575d10680675d207b4894a22 Mon Sep 17 00:00:00 2001 From: Aleksandr Soloshenko Date: Thu, 3 Sep 2026 09:17:43 +0700 Subject: [PATCH] [gateway] explicit registration, unregister feature --- .../capcom/smsgateway/helpers/SSEManager.kt | 38 +++- .../modules/gateway/EventsReceiver.kt | 12 +- .../gateway/services/SSEForegroundService.kt | 8 +- .../gateway/workers/RegistrationWorker.kt | 20 +- .../modules/receiver/EventsReceiver.kt | 10 + .../capcom/smsgateway/services/PushService.kt | 20 +- .../me/capcom/smsgateway/ui/HomeFragment.kt | 103 +++++---- .../settings/CloudServerSettingsFragment.kt | 195 +++++++++++++++++- app/src/main/res/values-ar/strings.xml | 6 +- app/src/main/res/values-zh/strings.xml | 3 +- app/src/main/res/values/strings.xml | 7 + .../main/res/xml/cloud_server_preferences.xml | 10 + 12 files changed, 360 insertions(+), 72 deletions(-) diff --git a/app/src/main/java/me/capcom/smsgateway/helpers/SSEManager.kt b/app/src/main/java/me/capcom/smsgateway/helpers/SSEManager.kt index 289cecc9f..e4a45b5a9 100644 --- a/app/src/main/java/me/capcom/smsgateway/helpers/SSEManager.kt +++ b/app/src/main/java/me/capcom/smsgateway/helpers/SSEManager.kt @@ -17,13 +17,14 @@ import java.util.concurrent.atomic.AtomicBoolean class SSEManager( private val url: String, - private val authToken: String + private val tokenProvider: () -> String?, ) { private val client = OkHttpClient.Builder() .readTimeout(1, TimeUnit.HOURS) .build() private val scope = CoroutineScope(Dispatchers.IO + Job()) + @Volatile private var eventSource: EventSource? = null private var reconnectAttempts = 0 private val isDisconnecting = AtomicBoolean(false) @@ -36,18 +37,21 @@ class SSEManager( fun connect() { isDisconnecting.set(false) + val token = tokenProvider() ?: return disconnect() + scope.launch { try { val request = Request.Builder() .url(url) .apply { - header("Authorization", "Bearer $authToken") + header("Authorization", "Bearer $token") } .build() - eventSource = EventSources.createFactory(client) + val source = EventSources.createFactory(client) .newEventSource(request, object : EventSourceListener() { override fun onOpen(eventSource: EventSource, response: Response) { + if (isDisconnecting.get()) return Log.d(TAG, "SSE connected") reconnectAttempts = 0 onConnected?.invoke() @@ -59,11 +63,13 @@ class SSEManager( type: String?, data: String ) { + if (isDisconnecting.get()) return Log.d(TAG, "Event received: $type - $data") onEvent?.invoke(type, data) } override fun onClosed(eventSource: EventSource) { + if (isDisconnecting.get()) return Log.d(TAG, "SSE connection closed") onClosed?.invoke() scheduleReconnect() @@ -74,11 +80,23 @@ class SSEManager( t: Throwable?, response: Response? ) { + if (isDisconnecting.get()) return Log.e(TAG, "SSE error", t) onError?.invoke(t) scheduleReconnect() } }) + + // This body has no suspension point, so disconnect()'s + // cancelChildren() cannot stop it once started: it may store the + // stream after teardown already cleared the field. Store first, + // then verify — paired with disconnect() setting the flag before + // it grabs the field, one of the two always cancels the loser. + eventSource = source + if (isDisconnecting.get()) { + eventSource = null + source.cancel() + } } catch (e: Exception) { Log.e(TAG, "Connection failed", e) scheduleReconnect() @@ -88,12 +106,14 @@ class SSEManager( fun disconnect() { isDisconnecting.set(true) - scope.launch { - eventSource?.cancel() - eventSource = null - reconnectAttempts = 0 - } + // Kill delayed reconnects and in-flight launches FIRST: this teardown must + // never be scheduled as a child, because cancelChildren() would cancel it + // before it runs and leave the authenticated stream alive. scope.coroutineContext.cancelChildren() + val source = eventSource + eventSource = null + reconnectAttempts = 0 + source?.cancel() } private fun scheduleReconnect() { @@ -111,8 +131,10 @@ class SSEManager( scope.launch { eventSource?.cancel() eventSource = null + if (isDisconnecting.get()) return@launch Log.d(TAG, "Reconnecting in ${delay}ms (attempt $reconnectAttempts)") kotlinx.coroutines.delay(delay) + if (isDisconnecting.get()) return@launch connect() } } diff --git a/app/src/main/java/me/capcom/smsgateway/modules/gateway/EventsReceiver.kt b/app/src/main/java/me/capcom/smsgateway/modules/gateway/EventsReceiver.kt index c12d475d3..c7e01aa75 100644 --- a/app/src/main/java/me/capcom/smsgateway/modules/gateway/EventsReceiver.kt +++ b/app/src/main/java/me/capcom/smsgateway/modules/gateway/EventsReceiver.kt @@ -134,9 +134,13 @@ class EventsReceiver : EventsReceiver() { Log.d("EventsReceiver", "Event: $it") if (!settings.enabled) return@collect - if (settings.fcmToken != null) return@collect - - SSEForegroundService.start(get()) + if (settings.fcmToken != null) { + // FCM is the active transport; tear down the SSE fallback so + // the same cloud command is not delivered twice. + SSEForegroundService.stop(get()) + } else { + SSEForegroundService.start(get()) + } } catch (e: CancellationException) { throw e } catch (e: Exception) { @@ -179,7 +183,7 @@ class EventsReceiver : EventsReceiver() { if (!settings.enabled) return@collect - RegistrationWorker.start(get(), settings.fcmToken, true) + RegistrationWorker.start(get(), settings.fcmToken) } } } diff --git a/app/src/main/java/me/capcom/smsgateway/modules/gateway/services/SSEForegroundService.kt b/app/src/main/java/me/capcom/smsgateway/modules/gateway/services/SSEForegroundService.kt index 3b4644031..73c2c95a2 100644 --- a/app/src/main/java/me/capcom/smsgateway/modules/gateway/services/SSEForegroundService.kt +++ b/app/src/main/java/me/capcom/smsgateway/modules/gateway/services/SSEForegroundService.kt @@ -43,9 +43,11 @@ class SSEForegroundService : Service() { private val sseManager by lazy { SSEManager( "${settings.serverUrl}/events", - requireNotNull( - settings.registrationInfo?.token - ) { "Authentication token is required for SSE connection" } + // Resolve the bearer token fresh on every connect() so account + // replacement picks up the new credentials without recreating the + // service. Returns null while unregistered, in which case connect() + // tears down gracefully instead of throwing. + tokenProvider = { settings.registrationInfo?.token } ) .apply { onConnected = { diff --git a/app/src/main/java/me/capcom/smsgateway/modules/gateway/workers/RegistrationWorker.kt b/app/src/main/java/me/capcom/smsgateway/modules/gateway/workers/RegistrationWorker.kt index 1d599a9cd..83237600e 100644 --- a/app/src/main/java/me/capcom/smsgateway/modules/gateway/workers/RegistrationWorker.kt +++ b/app/src/main/java/me/capcom/smsgateway/modules/gateway/workers/RegistrationWorker.kt @@ -12,7 +12,6 @@ import androidx.work.WorkRequest import androidx.work.WorkerParameters import androidx.work.workDataOf import me.capcom.smsgateway.App -import me.capcom.smsgateway.modules.gateway.GatewayService import me.capcom.smsgateway.modules.logs.LogsService import me.capcom.smsgateway.modules.logs.db.LogEntry import org.koin.core.component.KoinComponent @@ -27,17 +26,12 @@ class RegistrationWorker( override suspend fun doWork(): Result { try { - val token = inputData.getString(DATA_TOKEN) - val isUpdate = inputData.getBoolean(DATA_IS_UPDATE, false) + val fcmToken = inputData.getString(DATA_TOKEN) - when (isUpdate) { - true -> App.instance.gatewayService.updateDevice(applicationContext, token ?: return Result.success()) - false -> App.instance.gatewayService.registerDevice( - applicationContext, - token, - GatewayService.RegistrationMode.Anonymous - ) - } + App.instance.gatewayService.updateDevice( + applicationContext, + fcmToken, + ) return Result.success() } catch (e: Exception) { @@ -58,9 +52,8 @@ class RegistrationWorker( private const val NAME = "RegistrationWorker" private const val DATA_TOKEN = "token" - private const val DATA_IS_UPDATE = "isUpdate" - fun start(context: Context, token: String?, isUpdate: Boolean) { + fun start(context: Context, token: String?) { val work = OneTimeWorkRequestBuilder() .setConstraints( Constraints.Builder() @@ -75,7 +68,6 @@ class RegistrationWorker( .setInputData( workDataOf( DATA_TOKEN to token, - DATA_IS_UPDATE to isUpdate, ) ) .build() diff --git a/app/src/main/java/me/capcom/smsgateway/modules/receiver/EventsReceiver.kt b/app/src/main/java/me/capcom/smsgateway/modules/receiver/EventsReceiver.kt index 90bd03a4c..a3986f5f6 100644 --- a/app/src/main/java/me/capcom/smsgateway/modules/receiver/EventsReceiver.kt +++ b/app/src/main/java/me/capcom/smsgateway/modules/receiver/EventsReceiver.kt @@ -5,12 +5,14 @@ import kotlinx.coroutines.coroutineScope import kotlinx.coroutines.launch import me.capcom.smsgateway.modules.events.EventBus import me.capcom.smsgateway.modules.events.EventsReceiver +import me.capcom.smsgateway.modules.gateway.GatewaySettings import me.capcom.smsgateway.modules.receiver.events.MessagesExportRequestedEvent import org.koin.core.component.get import org.koin.core.component.inject class EventsReceiver : EventsReceiver() { private val service by inject() + private val gatewaySettings by inject() override suspend fun collect(eventBus: EventBus) { coroutineScope { @@ -19,6 +21,14 @@ class EventsReceiver : EventsReceiver() { eventBus.collect { event -> Log.d("EventsReceiver", "Event: $event") + // This receiver is not stopped by GatewayService.stop(), so it keeps + // collecting after unregister. Drop the command unless the device is + // still registered with the cloud server. + if (gatewaySettings.registrationInfo == null) { + Log.d("EventsReceiver", "Export request ignored: device is not registered") + return@collect + } + service.export( context = get(), period = event.since to event.until, diff --git a/app/src/main/java/me/capcom/smsgateway/services/PushService.kt b/app/src/main/java/me/capcom/smsgateway/services/PushService.kt index bc670de66..59e77c5a1 100644 --- a/app/src/main/java/me/capcom/smsgateway/services/PushService.kt +++ b/app/src/main/java/me/capcom/smsgateway/services/PushService.kt @@ -19,15 +19,29 @@ import org.koin.core.component.inject class PushService : FirebaseMessagingService(), KoinComponent { private val logsService by inject() private val eventsRouter by inject() + private val gatewaySettings by inject() override fun onNewToken(token: String) { - RegistrationWorker.start(this@PushService, token, true) + RegistrationWorker.start(this@PushService, token) } override fun onMessageReceived(message: RemoteMessage) { try { Log.d(this.javaClass.name, message.data.toString()) + // The server keeps the push token after unregister, so it may still deliver + // commands for the previous registration. Reject them here, at the entry + // point, rather than letting each consumer re-check the registration. + if (gatewaySettings.registrationInfo == null) { + Log.d(this.javaClass.name, "Push message ignored: device is not registered") + logsService.insert( + priority = LogEntry.Priority.INFO, + module = this.javaClass.simpleName, + message = "Push message ignored: device is not registered" + ) + return + } + val event = message.data["event"]?.let { ExternalEventType.valueOf(it) } ?: ExternalEventType.MessageEnqueued val data = message.data["data"] @@ -62,7 +76,7 @@ class PushService : FirebaseMessagingService(), KoinComponent { module = PushService::class.java.simpleName, message = "FCM registration skipped: SSE_ONLY channel configured" ) - RegistrationWorker.start(context, null, false) + RegistrationWorker.start(context, null) return } @@ -96,7 +110,7 @@ class PushService : FirebaseMessagingService(), KoinComponent { ) // Log and toast - RegistrationWorker.start(context, token, false) + RegistrationWorker.start(context, token) } } } diff --git a/app/src/main/java/me/capcom/smsgateway/ui/HomeFragment.kt b/app/src/main/java/me/capcom/smsgateway/ui/HomeFragment.kt index 1c41a4289..b3eb665a0 100644 --- a/app/src/main/java/me/capcom/smsgateway/ui/HomeFragment.kt +++ b/app/src/main/java/me/capcom/smsgateway/ui/HomeFragment.kt @@ -27,6 +27,7 @@ import androidx.fragment.app.Fragment import androidx.fragment.app.setFragmentResultListener import androidx.lifecycle.MediatorLiveData import androidx.lifecycle.lifecycleScope +import kotlinx.coroutines.CancellationException import kotlinx.coroutines.launch import me.capcom.smsgateway.R import me.capcom.smsgateway.databinding.FragmentHomeBinding @@ -45,6 +46,8 @@ import org.koin.android.ext.android.inject class HomeFragment : Fragment() { + private var registrationInFlight = false + private var _binding: FragmentHomeBinding? = null private val binding get() = _binding!! @@ -75,52 +78,75 @@ class HomeFragment : Fragment() { return@setFragmentResultListener } - FirstStartDialogFragment.Result.SignUp -> requestPermissionsAndStart() + FirstStartDialogFragment.Result.SignUp -> { + registerAndStart(GatewayService.RegistrationMode.Anonymous) + } FirstStartDialogFragment.Result.SignIn -> { - val username = FirstStartDialogFragment.getUsername(data) - val password = FirstStartDialogFragment.getPassword(data) - lifecycleScope.launch { - try { - gatewaySvc.registerDevice( - requireContext(), - null, - GatewayService.RegistrationMode.WithCredentials(username, password) - ) - requestPermissionsAndStart() - } catch (th: Throwable) { - Toast.makeText( - requireContext(), - "Failed to register device: ${th.message}", - Toast.LENGTH_LONG - ).show() - } - } + registerAndStart( + GatewayService.RegistrationMode.WithCredentials( + FirstStartDialogFragment.getUsername(data), + FirstStartDialogFragment.getPassword(data), + ) + ) } FirstStartDialogFragment.Result.SignInByCode -> { - val code = FirstStartDialogFragment.getCode(data) - lifecycleScope.launch { - try { - gatewaySvc.registerDevice( - requireContext(), - null, - GatewayService.RegistrationMode.WithCode(code) - ) - requestPermissionsAndStart() - } catch (th: Throwable) { - Toast.makeText( - requireContext(), - "Failed to register device: ${th.message}", - Toast.LENGTH_LONG - ).show() - } - } + registerAndStart( + GatewayService.RegistrationMode.WithCode( + FirstStartDialogFragment.getCode(data) + ) + ) } } } } + private fun registerAndStart(mode: GatewayService.RegistrationMode) { + // Each registration creates a separate account and device, and the last response + // wins, so a second request must not start while one is still in flight. + if (registrationInFlight) { + Log.d(javaClass.name, "Device registration already in progress") + return + } + registrationInFlight = true + setStartEnabled(false) + + lifecycleScope.launch { + try { + // Capture the application context up front: the fragment may be detached by + // the time the network call returns, and requireContext() would then throw. + val appContext = requireContext().applicationContext + gatewaySvc.registerDevice( + appContext, + null, + mode + ) + requestPermissionsAndStart() + } catch (e: CancellationException) { + // Lifecycle cancellation, not a registration failure: keep it propagating + // instead of reporting it as an error. + throw e + } catch (e: Exception) { + // `context` rather than `requireContext()`: the fragment may already be + // detached, and requiring a context here would throw outside the handler. + val ctx = context ?: return@launch + Toast.makeText( + ctx, + "Failed to register device: ${e.message}", + Toast.LENGTH_LONG + ).show() + } finally { + registrationInFlight = false + setStartEnabled(true) + } + } + } + + private fun setStartEnabled(enabled: Boolean) { + _binding?.buttonStart?.isEnabled = enabled + } + override fun onCreateView( inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle? @@ -346,6 +372,11 @@ class HomeFragment : Fragment() { private fun actionStart(start: Boolean) { if (start) { + if (registrationInFlight) { + Log.d(javaClass.name, "Device registration already in progress") + return + } + if (gatewaySettings.enabled && gatewaySettings.registrationInfo == null ) { diff --git a/app/src/main/java/me/capcom/smsgateway/ui/settings/CloudServerSettingsFragment.kt b/app/src/main/java/me/capcom/smsgateway/ui/settings/CloudServerSettingsFragment.kt index ea8157232..734deb28a 100644 --- a/app/src/main/java/me/capcom/smsgateway/ui/settings/CloudServerSettingsFragment.kt +++ b/app/src/main/java/me/capcom/smsgateway/ui/settings/CloudServerSettingsFragment.kt @@ -1,6 +1,7 @@ package me.capcom.smsgateway.ui.settings import android.annotation.SuppressLint +import android.content.SharedPreferences import android.os.Bundle import android.text.InputType import android.view.View @@ -10,10 +11,12 @@ import androidx.fragment.app.setFragmentResultListener import androidx.lifecycle.lifecycleScope import androidx.preference.EditTextPreference import androidx.preference.Preference +import kotlinx.coroutines.CancellationException import kotlinx.coroutines.launch import me.capcom.smsgateway.R import me.capcom.smsgateway.modules.gateway.GatewayService import me.capcom.smsgateway.modules.gateway.GatewaySettings +import me.capcom.smsgateway.ui.dialogs.FirstStartDialogFragment import me.capcom.smsgateway.ui.dialogs.PasswordPromptDialogFragment import org.koin.android.ext.android.inject import java.net.URL @@ -122,6 +125,57 @@ class CloudServerSettingsFragment : BasePreferenceFragment() { } } + findPreference("gateway.sign_in")?.apply { + isVisible = settings.enabled + + onPreferenceClickListener = Preference.OnPreferenceClickListener { + FirstStartDialogFragment.newInstance() + .show(parentFragmentManager, "signin") + true + } + } + + findPreference("gateway.unregister")?.apply { + isVisible = isUnregisterVisible() + + onPreferenceClickListener = Preference.OnPreferenceClickListener { + androidx.appcompat.app.AlertDialog.Builder(requireContext()) + .setTitle(R.string.unregister_device) + .setMessage(R.string.confirm_unregister_device) + .setPositiveButton(R.string.confirm) { _, _ -> + unregisterDevice() + } + .setNegativeButton(R.string.cancel, null) + .show() + true + } + } + + setFragmentResultListener(FirstStartDialogFragment.REQUEST_KEY) { _, data -> + when (FirstStartDialogFragment.getResult(data)) { + FirstStartDialogFragment.Result.Canceled -> { + return@setFragmentResultListener + } + + FirstStartDialogFragment.Result.SignUp -> registerDeviceInternal( + GatewayService.RegistrationMode.Anonymous + ) + + FirstStartDialogFragment.Result.SignIn -> registerDeviceInternal( + GatewayService.RegistrationMode.WithCredentials( + FirstStartDialogFragment.getUsername(data), + FirstStartDialogFragment.getPassword(data) + ) + ) + + FirstStartDialogFragment.Result.SignInByCode -> registerDeviceInternal( + GatewayService.RegistrationMode.WithCode( + FirstStartDialogFragment.getCode(data) + ) + ) + } + } + setFragmentResultListener(PasswordPromptDialogFragment.REQUEST_KEY) { _, data -> val password = PasswordPromptDialogFragment.getPassword(data) ?: return@setFragmentResultListener @@ -141,6 +195,144 @@ class CloudServerSettingsFragment : BasePreferenceFragment() { } } + override fun onResume() { + super.onResume() + + updateActionsVisibility() + preferenceManager.sharedPreferences?.registerOnSharedPreferenceChangeListener( + onPreferenceChanged + ) + } + + override fun onPause() { + preferenceManager.sharedPreferences?.unregisterOnSharedPreferenceChangeListener( + onPreferenceChanged + ) + + super.onPause() + } + + private val onPreferenceChanged = + SharedPreferences.OnSharedPreferenceChangeListener { _, key -> + if (key == "gateway.ENABLED") { + updateActionsVisibility() + } + } + + private fun updateActionsVisibility() { + findPreference("gateway.sign_in")?.isVisible = settings.enabled + findPreference("gateway.unregister")?.isVisible = isUnregisterVisible() + listView.adapter?.notifyDataSetChanged() + } + + // Unregister only forgets the registration locally; it cannot detach the device from the + // account, because the cloud server offers no way to clear the push token it holds. + // `PATCH /device` maps both an absent and an empty `pushToken` to nil and then omits + // `push_token` from the update, so it answers 204 without changing anything, and the + // only destructive alternative (deleting the device row) cascades to its messages, + // webhooks and inbox rows. Consequence: the server can still push to this device for + // the old registration once the user registers again. PushService and the receiver-side + // MessagesExportRequested collector drop commands while `registrationInfo == null`, + // which covers the unregistered window but not the re-registered one. Closing that + // needs a server contract change; do not read the toast as "server-side revoked". + private fun isUnregisterVisible() = settings.enabled && settings.registrationInfo != null + + private fun registerDeviceInternal(mode: GatewayService.RegistrationMode) { + lifecycleScope.launch { + // Capture the application context up front: the fragment may be detached by + // the time the network call returns, so requireContext() would throw and + // abort SSE startup and the subsequent service.start() for a registered device. + val appContext = requireContext().applicationContext + val previous = settings.registrationInfo + val hadSession = previous != null + try { + activity?.findViewById(R.id.progressBar)?.isVisible = true + // Tear down any active SSE stream bound to the old credentials BEFORE + // swapping them. The bearer token is now resolved fresh on every + // SSEManager.connect() via a token provider, so the manager does NOT + // cache stale credentials and need not be recreated for credential + // refresh; we stop the service up front only to close the old stream. + service.stop(appContext) + // Explicit registration must take effect even when already registered: + // drop stale credentials so registerDevice() does not degrade into + // a push-token update of the current registration. + settings.registrationInfo = null + + service.registerDevice(appContext, null, mode) + + // Revive the standard pipeline (workers + EventsReceiver) only AFTER + // registration finished: start() is idempotent, and running it earlier + // could race the registration event emission above. + // Registration succeeded and is already persisted locally + remotely. + // Post-success UI must NOT roll back credentials if the fragment detaches. + try { + service.start(appContext) + refreshRegistrationUi() + showToast(R.string.device_registered_successfully) + } catch (_: Exception) { + // Non-fatal: credentials and remote registration are already in place. + } + } catch (e: CancellationException) { + // Lifecycle cancellation: do not blindly roll back credentials. + // registerDevice persists locally only AFTER the network succeeds. + // - If it completed (registrationInfo != null), the new registration + // is already persisted; revive the pipeline and rethrow. + // - If it didn't complete (registrationInfo == null), restore the + // previous state and revive the pipeline if there was one. + // NOTE: service.start() revives the workers and the events receiver but does + // NOT start SSE. DeviceRegisteredEvent.Success is emitted while the receiver + // is stopped, so that event has no collector here. SSE resumes only indirectly, + // when the FCM RegistrationWorker re-runs updateDevice() and re-emits the event + // to the restarted receiver. That is a known gap, deliberately left as-is for + // now: the interim workaround is the restart-app hint in + // device_registered_successfully. Track it before relying on this path. + if (settings.registrationInfo != null) { + service.start(appContext) + } else if (hadSession) { + service.stop(appContext) + settings.registrationInfo = previous + service.start(appContext) + } + throw e + } catch (e: Exception) { + service.stop(appContext) + settings.registrationInfo = previous + if (hadSession) { + service.start(appContext) + } + refreshRegistrationUi() + showToast(getString(R.string.failed_to_register_device, e.message)) + return@launch + } finally { + // Hide the progress bar on BOTH success and failure paths. The failure + // catch uses return@launch, so the outer finally is required to guarantee + // the spinner is dismissed instead of lingering until the next UI refresh. + activity?.findViewById(R.id.progressBar)?.isVisible = false + } + } + } + + private fun unregisterDevice() { + // Local-only: there is no server-side detach available today. See the comment on + // isUnregisterVisible() for what this does and does not stop. + settings.registrationInfo = null + // Stop SSE session and cloud workers so they do not keep using + // credentials of the just-unregistered device. + service.stop(requireContext()) + refreshRegistrationUi() + showToast(R.string.device_unregistered) + } + + private fun refreshRegistrationUi() { + findPreference("gateway.password")?.isEnabled = settings.username != null + findPreference("gateway.clear_password")?.isVisible = settings.hasPassword + findPreference("gateway.login_code")?.isVisible = settings.username != null + findPreference("gateway.unregister")?.isVisible = isUnregisterVisible() + findPreference("transient.device_id")?.summary = + settings.deviceId ?: getString(R.string.n_a) + listView.adapter?.notifyDataSetChanged() + } + private fun showPasswordPromptDialog( message: String, action: String? = null, @@ -155,7 +347,8 @@ class CloudServerSettingsFragment : BasePreferenceFragment() { try { requireActivity().findViewById(R.id.progressBar).isVisible = true service.changePassword(currentPassword, newPassword) - findPreference("gateway.clear_password")?.isVisible = settings.hasPassword + findPreference("gateway.clear_password")?.isVisible = + settings.hasPassword listView.adapter?.notifyDataSetChanged() showToast(R.string.password_changed_successfully) } catch (e: Exception) { diff --git a/app/src/main/res/values-ar/strings.xml b/app/src/main/res/values-ar/strings.xml index a49a87dba..3e1dd4a4e 100644 --- a/app/src/main/res/values-ar/strings.xml +++ b/app/src/main/res/values-ar/strings.xml @@ -11,7 +11,8 @@ استمرار بواسطة الكود يمكن أن يؤثر على عمر البطارية - انقر + انقر فوق استمرار لإنشاء حساب. لا يلزم تقديم معلومات شخصية.\nمن خلال الاستمرار، فإنك توافق على سياسة الخصوصية الخاصة بنا على https://docs.sms-gate.app/privacy/policy/ خادم السحاب… @@ -161,7 +162,8 @@ بدء الخدمة إيقاف الخدمة مراقبة موفر المحتوى - اكتشاف الرسائل الواردة أيضًا عبر موفر المحتوى + اكتشاف الرسائل الواردة أيضًا عبر + موفر المحتوى ساعات العمل تفعيل ساعات العمل إرسال الرسائل فقط ضمن الفترة الزمنية المحددة diff --git a/app/src/main/res/values-zh/strings.xml b/app/src/main/res/values-zh/strings.xml index ce07e6d0f..4de105123 100644 --- a/app/src/main/res/values-zh/strings.xml +++ b/app/src/main/res/values-zh/strings.xml @@ -9,7 +9,8 @@ 继续 通过验证码 可能影响电池寿命 - 点击"继续"创建账户,无需提供个人信息。\n继续即表示您同意我们的隐私政策,详情见 + 点击"继续"创建账户,无需提供个人信息。\n继续即表示您同意我们的隐私政策,详情见 https://docs.sms-gate.app/privacy/policy/ 云服务器… 云服务器 diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 0e50d2cde..36a4b46cb 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -115,6 +115,7 @@ <a href>%1$s:%2$d</a> Start on boot Sign In + Sign in to existing account Sign Up Signing Key SMSGate is running on port %1$d @@ -215,4 +216,10 @@ MMS (%1$d) Device Key Device keys management + Device registered successfully, restart the app + before continuing. + Device unregistered + Unregister device + Are you sure you want to unregister this device? + You will need to sign up or sign in again to use the cloud server. \ No newline at end of file diff --git a/app/src/main/res/xml/cloud_server_preferences.xml b/app/src/main/res/xml/cloud_server_preferences.xml index 67ffeac6e..9cd96896b 100644 --- a/app/src/main/res/xml/cloud_server_preferences.xml +++ b/app/src/main/res/xml/cloud_server_preferences.xml @@ -47,6 +47,11 @@ android:summary="@string/use_this_code_to_sign_in_on_another_device" android:title="@string/login_code" app:enableCopying="true" /> + +