diff --git a/app/src/main/assets/linphonerc_factory b/app/src/main/assets/linphonerc_factory
index 9002c427b..d33320b24 100644
--- a/app/src/main/assets/linphonerc_factory
+++ b/app/src/main/assets/linphonerc_factory
@@ -49,6 +49,7 @@ record_aware=1
[account_creator]
url=https://subscribe.linphone.org/api/
+backend=1
[lime]
lime_update_threshold=86400
diff --git a/app/src/main/java/org/linphone/ui/assistant/fragment/LandingFragment.kt b/app/src/main/java/org/linphone/ui/assistant/fragment/LandingFragment.kt
index e25239872..852adf9a9 100644
--- a/app/src/main/java/org/linphone/ui/assistant/fragment/LandingFragment.kt
+++ b/app/src/main/java/org/linphone/ui/assistant/fragment/LandingFragment.kt
@@ -103,22 +103,10 @@ class LandingFragment : GenericFragment() {
}
binding.setForgottenPasswordClickListener {
- val url = getString(R.string.web_platform_forgotten_password_url)
- try {
- val browserIntent = Intent(Intent.ACTION_VIEW, url.toUri())
- startActivity(browserIntent)
- } catch (ise: IllegalStateException) {
- Log.e(
- "$TAG Can't start ACTION_VIEW intent for URL [$url], IllegalStateException: $ise"
- )
- } catch (anfe: ActivityNotFoundException) {
- Log.e(
- "$TAG Can't start ACTION_VIEW intent for URL [$url], ActivityNotFoundException: $anfe"
- )
- } catch (e: Exception) {
- Log.e(
- "$TAG Can't start ACTION_VIEW intent for URL [$url]: $e"
- )
+ if (findNavController().currentDestination?.id == R.id.landingFragment) {
+ val action =
+ LandingFragmentDirections.actionLandingFragmentToRecoverAccountFragment()
+ findNavController().navigate(action)
}
}
diff --git a/app/src/main/java/org/linphone/ui/assistant/fragment/RecoverAccountFragment.kt b/app/src/main/java/org/linphone/ui/assistant/fragment/RecoverAccountFragment.kt
new file mode 100644
index 000000000..9471d83aa
--- /dev/null
+++ b/app/src/main/java/org/linphone/ui/assistant/fragment/RecoverAccountFragment.kt
@@ -0,0 +1,106 @@
+/*
+ * Copyright (c) 2010-2025 Belledonne Communications SARL.
+ *
+ * This file is part of linphone-android
+ * (see https://www.linphone.org).
+ *
+ * This program 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.
+ *
+ * This program 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 this program. If not, see .
+ */
+package org.linphone.ui.assistant.fragment
+
+import android.content.ActivityNotFoundException
+import android.content.Intent
+import android.os.Bundle
+import android.view.LayoutInflater
+import android.view.View
+import android.view.ViewGroup
+import androidx.annotation.UiThread
+import androidx.core.net.toUri
+import androidx.navigation.fragment.findNavController
+import androidx.navigation.navGraphViewModels
+import org.linphone.R
+import org.linphone.core.tools.Log
+import org.linphone.ui.GenericFragment
+import org.linphone.databinding.AssistantRecoverAccountFragmentBinding
+import org.linphone.ui.assistant.viewmodel.RecoverPhoneAccountViewModel
+import kotlin.getValue
+
+@UiThread
+class RecoverAccountFragment : GenericFragment() {
+ companion object {
+ private const val TAG = "[Recover Account Fragment]"
+ }
+
+ private lateinit var binding: AssistantRecoverAccountFragmentBinding
+
+ private val viewModel: RecoverPhoneAccountViewModel by navGraphViewModels(
+ R.id.assistant_nav_graph
+ )
+
+ override fun onCreateView(
+ inflater: LayoutInflater,
+ container: ViewGroup?,
+ savedInstanceState: Bundle?
+ ): View {
+ binding = AssistantRecoverAccountFragmentBinding.inflate(layoutInflater)
+ return binding.root
+ }
+
+ override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
+ super.onViewCreated(view, savedInstanceState)
+
+ binding.lifecycleOwner = viewLifecycleOwner
+ binding.viewModel = viewModel
+ observeToastEvents(viewModel)
+
+ binding.setBackClickListener {
+ goBack()
+ }
+
+ binding.setRecoverEmailAccountClickListener {
+ recoverEmailAccount()
+ }
+
+ binding.setRecoverPhoneNumberAccountClickListener {
+ if (findNavController().currentDestination?.id == R.id.recoverAccountFragment) {
+ val action = RecoverAccountFragmentDirections.actionRecoverAccountFragmentToRecoverPhoneAccountFragment()
+ findNavController().navigate(action)
+ }
+ }
+ }
+
+ private fun goBack() {
+ findNavController().popBackStack()
+ }
+
+ private fun recoverEmailAccount() {
+ val url = getString(R.string.web_platform_forgotten_password_url)
+ try {
+ val browserIntent = Intent(Intent.ACTION_VIEW, url.toUri())
+ startActivity(browserIntent)
+ } catch (ise: IllegalStateException) {
+ Log.e(
+ "$TAG Can't start ACTION_VIEW intent for URL [$url], IllegalStateException: $ise"
+ )
+ } catch (anfe: ActivityNotFoundException) {
+ Log.e(
+ "$TAG Can't start ACTION_VIEW intent for URL [$url], ActivityNotFoundException: $anfe"
+ )
+ } catch (e: Exception) {
+ Log.e(
+ "$TAG Can't start ACTION_VIEW intent for URL [$url]: $e"
+ )
+ }
+ }
+}
diff --git a/app/src/main/java/org/linphone/ui/assistant/fragment/RecoverPhoneAccountCodeConfirmationFragment.kt b/app/src/main/java/org/linphone/ui/assistant/fragment/RecoverPhoneAccountCodeConfirmationFragment.kt
new file mode 100644
index 000000000..77715e7b1
--- /dev/null
+++ b/app/src/main/java/org/linphone/ui/assistant/fragment/RecoverPhoneAccountCodeConfirmationFragment.kt
@@ -0,0 +1,100 @@
+/*
+ * Copyright (c) 2010-2025 Belledonne Communications SARL.
+ *
+ * This file is part of linphone-android
+ * (see https://www.linphone.org).
+ *
+ * This program 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.
+ *
+ * This program 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 this program. If not, see .
+ */
+package org.linphone.ui.assistant.fragment
+
+import android.content.ClipboardManager
+import android.content.Context
+import android.os.Bundle
+import android.view.LayoutInflater
+import android.view.View
+import android.view.ViewGroup
+import androidx.annotation.UiThread
+import androidx.navigation.fragment.findNavController
+import androidx.navigation.navGraphViewModels
+import org.linphone.R
+import org.linphone.core.tools.Log
+import org.linphone.databinding.AssistantRecoverPhoneAccountConfirmSmsCodeFragmentBinding
+import org.linphone.ui.GenericFragment
+import org.linphone.ui.assistant.viewmodel.RecoverPhoneAccountViewModel
+
+@UiThread
+class RecoverPhoneAccountCodeConfirmationFragment : GenericFragment() {
+ companion object {
+ private const val TAG = "[Recover Phone Account Code Confirmation Fragment]"
+ }
+
+ private lateinit var binding: AssistantRecoverPhoneAccountConfirmSmsCodeFragmentBinding
+
+ private val viewModel: RecoverPhoneAccountViewModel by navGraphViewModels(
+ R.id.assistant_nav_graph
+ )
+
+ override fun onCreateView(
+ inflater: LayoutInflater,
+ container: ViewGroup?,
+ savedInstanceState: Bundle?
+ ): View {
+ binding = AssistantRecoverPhoneAccountConfirmSmsCodeFragmentBinding.inflate(layoutInflater)
+ return binding.root
+ }
+
+ override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
+ super.onViewCreated(view, savedInstanceState)
+
+ binding.lifecycleOwner = viewLifecycleOwner
+ binding.viewModel = viewModel
+ observeToastEvents(viewModel)
+
+ binding.setBackClickListener {
+ goBack()
+ }
+
+ viewModel.accountCreatedEvent.observe(viewLifecycleOwner) {
+ it.consume { identity ->
+ Log.i("$TAG Account [$identity] has been created, leaving assistant")
+ requireActivity().finish()
+ }
+ }
+
+ // This won't work starting Android 10 as clipboard access is denied unless app has focus,
+ // which won't be the case when the SMS arrives unless it is added into clipboard from a notification
+ val clipboard = requireContext().getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager
+ clipboard.addPrimaryClipChangedListener {
+ val data = clipboard.primaryClip
+ if (data != null && data.itemCount > 0) {
+ val clip = data.getItemAt(0).text.toString()
+ if (clip.length == 4) {
+ Log.i(
+ "$TAG Found 4 digits [$clip] as primary clip in clipboard, using it and clear it"
+ )
+ viewModel.smsCodeFirstDigit.value = clip[0].toString()
+ viewModel.smsCodeSecondDigit.value = clip[1].toString()
+ viewModel.smsCodeThirdDigit.value = clip[2].toString()
+ viewModel.smsCodeLastDigit.value = clip[3].toString()
+ clipboard.clearPrimaryClip()
+ }
+ }
+ }
+ }
+
+ private fun goBack() {
+ findNavController().popBackStack()
+ }
+}
diff --git a/app/src/main/java/org/linphone/ui/assistant/fragment/RecoverPhoneAccountFragment.kt b/app/src/main/java/org/linphone/ui/assistant/fragment/RecoverPhoneAccountFragment.kt
new file mode 100644
index 000000000..81f03ad13
--- /dev/null
+++ b/app/src/main/java/org/linphone/ui/assistant/fragment/RecoverPhoneAccountFragment.kt
@@ -0,0 +1,183 @@
+/*
+ * Copyright (c) 2010-2025 Belledonne Communications SARL.
+ *
+ * This file is part of linphone-android
+ * (see https://www.linphone.org).
+ *
+ * This program 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.
+ *
+ * This program 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 this program. If not, see .
+ */
+package org.linphone.ui.assistant.fragment
+
+import android.content.Context
+import android.os.Bundle
+import android.telephony.TelephonyManager
+import android.text.Editable
+import android.text.TextWatcher
+import android.view.LayoutInflater
+import android.view.View
+import android.view.ViewGroup
+import android.widget.AdapterView
+import android.widget.ArrayAdapter
+import androidx.annotation.UiThread
+import androidx.appcompat.widget.AppCompatTextView
+import androidx.navigation.fragment.findNavController
+import androidx.navigation.navGraphViewModels
+import org.linphone.LinphoneApplication.Companion.coreContext
+import org.linphone.R
+import org.linphone.core.tools.Log
+import org.linphone.ui.GenericFragment
+import org.linphone.databinding.AssistantRecoverPhoneAccountFragmentBinding
+import org.linphone.ui.assistant.viewmodel.RecoverPhoneAccountViewModel
+import org.linphone.utils.AppUtils
+import org.linphone.utils.ConfirmationDialogModel
+import org.linphone.utils.DialogUtils
+import org.linphone.utils.PhoneNumberUtils
+import kotlin.getValue
+
+@UiThread
+class RecoverPhoneAccountFragment : GenericFragment() {
+ companion object {
+ private const val TAG = "[Recover Phone Account Fragment]"
+ }
+
+ private lateinit var binding: AssistantRecoverPhoneAccountFragmentBinding
+
+ private val viewModel: RecoverPhoneAccountViewModel by navGraphViewModels(
+ R.id.assistant_nav_graph
+ )
+
+ private val dropdownListener = object : AdapterView.OnItemSelectedListener {
+ override fun onItemSelected(parent: AdapterView<*>?, view: View?, position: Int, id: Long) {
+ val dialPlan = viewModel.dialPlansList[position]
+ Log.i(
+ "$TAG Selected dialplan updated [+${dialPlan.countryCallingCode}] / [${dialPlan.country}]"
+ )
+ viewModel.selectedDialPlan.value = dialPlan
+ }
+
+ override fun onNothingSelected(parent: AdapterView<*>?) {
+ }
+ }
+
+ override fun onCreateView(
+ inflater: LayoutInflater,
+ container: ViewGroup?,
+ savedInstanceState: Bundle?
+ ): View {
+ binding = AssistantRecoverPhoneAccountFragmentBinding.inflate(layoutInflater)
+ return binding.root
+ }
+
+ override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
+ super.onViewCreated(view, savedInstanceState)
+
+ binding.lifecycleOwner = viewLifecycleOwner
+ binding.viewModel = viewModel
+ observeToastEvents(viewModel)
+
+ binding.setBackClickListener {
+ goBack()
+ }
+
+ binding.phoneNumber.addTextChangedListener(object : TextWatcher {
+ override fun afterTextChanged(s: Editable?) {
+ viewModel.phoneNumberError.value = ""
+ }
+
+ override fun beforeTextChanged(s: CharSequence?, start: Int, count: Int, after: Int) {}
+
+ override fun onTextChanged(s: CharSequence?, start: Int, before: Int, count: Int) {}
+ })
+
+ viewModel.normalizedPhoneNumberEvent.observe(viewLifecycleOwner) {
+ it.consume { number ->
+ Log.i("$TAG Showing confirmation dialog for phone number [$number]")
+ showPhoneNumberConfirmationDialog(number)
+ }
+ }
+
+ viewModel.goToSmsValidationEvent.observe(viewLifecycleOwner) {
+ it.consume {
+ if (findNavController().currentDestination?.id == R.id.recoverPhoneAccountFragment) {
+ Log.i("$TAG Going to SMS code validation fragment")
+ val action = RecoverPhoneAccountFragmentDirections.actionRecoverPhoneAccountFragmentToRecoverPhoneAccountCodeConfirmationFragment()
+ findNavController().navigate(action)
+ }
+ }
+ }
+
+ val telephonyManager = requireContext().getSystemService(Context.TELEPHONY_SERVICE) as TelephonyManager
+ val countryIso = telephonyManager.networkCountryIso
+ coreContext.postOnCoreThread {
+ val fragmentContext = context ?: return@postOnCoreThread
+
+ val adapter = object : ArrayAdapter(
+ fragmentContext,
+ R.layout.drop_down_item,
+ viewModel.dialPlansLabelList
+ ) {
+ override fun getView(position: Int, convertView: View?, parent: ViewGroup): View {
+ val view = convertView ?: super.getView(position, null, parent)
+ val label = viewModel.dialPlansShortLabelList[position]
+ (view as? AppCompatTextView)?.text = label
+ return view
+ }
+ }
+ adapter.setDropDownViewResource(R.layout.assistant_country_picker_dropdown_cell)
+
+ val dialPlan = PhoneNumberUtils.getDeviceDialPlan(countryIso)
+ var default = 0
+ if (dialPlan != null) {
+ viewModel.selectedDialPlan.postValue(dialPlan)
+ default = viewModel.dialPlansList.indexOf(dialPlan)
+ }
+
+ coreContext.postOnMainThread {
+ binding.prefix.adapter = adapter
+ binding.prefix.setSelection(default)
+ binding.prefix.onItemSelectedListener = dropdownListener
+ }
+ }
+ }
+
+ private fun goBack() {
+ findNavController().popBackStack()
+ }
+
+ private fun showPhoneNumberConfirmationDialog(number: String) {
+ val label = AppUtils.getFormattedString(R.string.assistant_dialog_confirm_phone_number_message, number)
+ val model = ConfirmationDialogModel(label)
+ val dialog = DialogUtils.getAccountCreationPhoneNumberConfirmationDialog(
+ requireActivity(),
+ model
+ )
+
+ model.dismissEvent.observe(viewLifecycleOwner) {
+ it.consume {
+ Log.w("$TAG User dismissed the dialog, aborting recovery process")
+ dialog.dismiss()
+ }
+ }
+
+ model.confirmEvent.observe(viewLifecycleOwner) {
+ it.consume {
+ Log.i("$TAG User confirmed the phone number, requesting account creation token & SMS code")
+ viewModel.startRecoveryProcess()
+ dialog.dismiss()
+ }
+ }
+
+ dialog.show()
+ }
+}
diff --git a/app/src/main/java/org/linphone/ui/assistant/viewmodel/AccountCreationViewModel.kt b/app/src/main/java/org/linphone/ui/assistant/viewmodel/AccountCreationViewModel.kt
index 2681eda1c..65ec3f1cc 100644
--- a/app/src/main/java/org/linphone/ui/assistant/viewmodel/AccountCreationViewModel.kt
+++ b/app/src/main/java/org/linphone/ui/assistant/viewmodel/AccountCreationViewModel.kt
@@ -365,7 +365,7 @@ class AccountCreationViewModel
val account = accountCreated
if (::accountManagerServices.isInitialized && account != null) {
val code =
- "${smsCodeFirstDigit.value}${smsCodeSecondDigit.value}${smsCodeThirdDigit.value}${smsCodeLastDigit.value}"
+ "${smsCodeFirstDigit.value.orEmpty().trim()}${smsCodeSecondDigit.value.orEmpty().trim()}${smsCodeThirdDigit.value.orEmpty().trim()}${smsCodeLastDigit.value.orEmpty().trim()}"
val identity = account.params.identityAddress
if (identity != null) {
Log.i(
diff --git a/app/src/main/java/org/linphone/ui/assistant/viewmodel/RecoverPhoneAccountViewModel.kt b/app/src/main/java/org/linphone/ui/assistant/viewmodel/RecoverPhoneAccountViewModel.kt
new file mode 100644
index 000000000..2290a1068
--- /dev/null
+++ b/app/src/main/java/org/linphone/ui/assistant/viewmodel/RecoverPhoneAccountViewModel.kt
@@ -0,0 +1,449 @@
+/*
+ * Copyright (c) 2010-2025 Belledonne Communications SARL.
+ *
+ * This file is part of linphone-android
+ * (see https://www.linphone.org).
+ *
+ * This program 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.
+ *
+ * This program 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 this program. If not, see .
+ */
+package org.linphone.ui.assistant.viewmodel
+
+import androidx.annotation.UiThread
+import androidx.annotation.WorkerThread
+import androidx.lifecycle.MediatorLiveData
+import androidx.lifecycle.MutableLiveData
+import androidx.lifecycle.viewModelScope
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.Job
+import kotlinx.coroutines.delay
+import kotlinx.coroutines.launch
+import kotlinx.coroutines.withContext
+import org.json.JSONException
+import org.json.JSONObject
+import org.linphone.LinphoneApplication.Companion.coreContext
+import org.linphone.LinphoneApplication.Companion.corePreferences
+import org.linphone.R
+import org.linphone.core.AccountCreator
+import org.linphone.core.AccountCreatorListenerStub
+import org.linphone.core.AccountManagerServices
+import org.linphone.core.AccountManagerServicesRequest
+import org.linphone.core.AccountManagerServicesRequestListenerStub
+import org.linphone.core.Core
+import org.linphone.core.CoreListenerStub
+import org.linphone.core.DialPlan
+import org.linphone.core.Dictionary
+import org.linphone.core.Factory
+import org.linphone.core.tools.Log
+import org.linphone.ui.GenericViewModel
+import org.linphone.utils.Event
+import org.linphone.utils.LinphoneUtils
+import java.util.Locale
+
+class RecoverPhoneAccountViewModel : GenericViewModel() {
+ companion object {
+ private const val TAG = "[Recover Phone Account ViewModel]"
+
+ private const val TIME_TO_WAIT_FOR_PUSH_NOTIFICATION_WITH_ACCOUNT_CREATION_TOKEN = 5000
+ }
+
+ val pushNotificationsAvailable = MutableLiveData()
+
+ val dialPlansLabelList = arrayListOf()
+
+ val dialPlansShortLabelList = arrayListOf()
+
+ val dialPlansList = arrayListOf()
+
+ val selectedDialPlan = MutableLiveData()
+
+ val phoneNumber = MutableLiveData()
+
+ val phoneNumberError = MutableLiveData()
+
+ val confirmationMessage = MutableLiveData()
+
+ val smsCodeFirstDigit = MutableLiveData()
+ val smsCodeSecondDigit = MutableLiveData()
+ val smsCodeThirdDigit = MutableLiveData()
+ val smsCodeLastDigit = MutableLiveData()
+
+ val operationInProgress = MutableLiveData()
+
+ val recoverEnabled = MediatorLiveData()
+
+ private var normalizedPhoneNumber: String? = null
+ val normalizedPhoneNumberEvent = MutableLiveData>()
+
+ val goToSmsValidationEvent = MutableLiveData>()
+
+ val accountCreatedEvent = MutableLiveData>()
+
+ private lateinit var accountManagerServices: AccountManagerServices
+ private val accountManagerServicesListener = object : AccountManagerServicesRequestListenerStub() {
+ @WorkerThread
+ override fun onRequestSuccessful(
+ request: AccountManagerServicesRequest,
+ data: String?
+ ) {
+ Log.i("$TAG Request [$request] was successful, data is [$data]")
+ operationInProgress.postValue(false)
+ }
+
+ @WorkerThread
+ override fun onRequestError(
+ request: AccountManagerServicesRequest,
+ statusCode: Int,
+ errorMessage: String?,
+ parameterErrors: Dictionary?
+ ) {
+ Log.e(
+ "$TAG Request [$request] returned an error with status code [$statusCode] and message [$errorMessage]"
+ )
+ operationInProgress.postValue(false)
+
+ if (!errorMessage.isNullOrEmpty()) {
+ showFormattedRedToast(errorMessage, R.drawable.warning_circle)
+ }
+
+ when (request.type) {
+ AccountManagerServicesRequest.Type.SendAccountCreationTokenByPush -> {
+ Log.w("$TAG Cancelling job waiting for push notification")
+ waitingForFlexiApiPushToken = false
+ waitForPushJob?.cancel()
+ }
+ else -> {
+ }
+ }
+ recoverEnabled.postValue(true)
+ }
+ }
+ private var accountCreationToken: String? = null
+
+ private var waitingForFlexiApiPushToken = false
+ private var waitForPushJob: Job? = null
+
+ private lateinit var accountCreator: AccountCreator
+ private val accountCreatorListener = object : AccountCreatorListenerStub() {
+ @WorkerThread
+ override fun onRecoverAccount(
+ creator: AccountCreator,
+ status: AccountCreator.Status,
+ response: String?
+ ) {
+ Log.i("$TAG Recover account status is $status")
+ operationInProgress.postValue(false)
+
+ if (status == AccountCreator.Status.RequestOk) {
+ goToSmsValidationEvent.postValue(Event(true))
+ } else {
+ Log.e("$TAG Error in onRecoverAccount [${status.name}]")
+ showFormattedRedToast(status.name, R.drawable.warning_circle)
+ }
+ }
+
+ @WorkerThread
+ override fun onLoginLinphoneAccount(
+ creator: AccountCreator,
+ status: AccountCreator.Status,
+ response: String?
+ ) {
+ Log.i("$TAG onLoginLinphoneAccount status is $status")
+ operationInProgress.postValue(false)
+
+ if (status == AccountCreator.Status.RequestOk) {
+ if (!createAccountAndAuthInfo()) {
+ Log.e("$TAG Failed to create account object")
+ }
+ } else {
+ Log.e("$TAG Error in onRecoverAccount [${status.name}]")
+ showFormattedRedToast(status.name, R.drawable.warning_circle)
+ }
+ }
+ }
+
+ private val coreListener = object : CoreListenerStub() {
+ @WorkerThread
+ override fun onPushNotificationReceived(core: Core, payload: String?) {
+ Log.i("$TAG Push received: [$payload]")
+
+ val data = payload.orEmpty()
+ if (data.isNotEmpty()) {
+ try {
+ // This is because JSONObject.toString() done by the SDK will result in payload looking like {"custom-payload":"{\"token\":\"value\"}"}
+ val cleanPayload = data
+ .replace("\\\"", "\"")
+ .replace("\"{", "{")
+ .replace("}\"", "}")
+ Log.i("$TAG Cleaned payload is: [$cleanPayload]")
+
+ val json = JSONObject(cleanPayload)
+ val customPayload = json.getJSONObject("custom-payload")
+ if (customPayload.has("token")) {
+ waitForPushJob?.cancel()
+ waitingForFlexiApiPushToken = false
+ operationInProgress.postValue(false)
+
+ val token = customPayload.getString("token")
+ if (token.isNotEmpty()) {
+ accountCreationToken = token
+ Log.i(
+ "$TAG Extracted token [$accountCreationToken] from push payload, recovering account"
+ )
+ requestSmsCode()
+ } else {
+ Log.e("$TAG Push payload JSON object has an empty 'token'!")
+ onFlexiApiTokenRequestError()
+ }
+ } else {
+ Log.e("$TAG Push payload JSON object has no 'token' key!")
+ onFlexiApiTokenRequestError()
+ }
+ } catch (e: JSONException) {
+ Log.e("$TAG Exception trying to parse push payload as JSON: [$e]")
+ onFlexiApiTokenRequestError()
+ }
+ } else {
+ Log.e("$TAG Push payload is null or empty, can't extract auth token!")
+ onFlexiApiTokenRequestError()
+ }
+ }
+ }
+
+ init {
+ coreContext.postOnCoreThread { core ->
+ core.addListener(coreListener)
+
+ pushNotificationsAvailable.postValue(LinphoneUtils.arePushNotificationsAvailable(core))
+
+ val dialPlans = Factory.instance().dialPlans.toList()
+ for (dialPlan in dialPlans) {
+ dialPlansList.add(dialPlan)
+ dialPlansLabelList.add(
+ "${dialPlan.flag} ${dialPlan.country} | +${dialPlan.countryCallingCode}"
+ )
+ dialPlansShortLabelList.add(
+ "${dialPlan.flag} +${dialPlan.countryCallingCode}"
+ )
+ }
+
+ accountManagerServices = core.createAccountManagerServices()
+ accountManagerServices.language = Locale.getDefault().language // Returns en, fr, etc...
+
+ accountCreator = core.createAccountCreator("https://subscribe.linphone.org/api/")
+ accountCreator.addListener(accountCreatorListener)
+ }
+
+ recoverEnabled.addSource(selectedDialPlan) {
+ recoverEnabled.value = phoneNumber.value.orEmpty().isNotEmpty() && selectedDialPlan.value?.countryCallingCode.orEmpty().isNotEmpty()
+ }
+ recoverEnabled.addSource(phoneNumber) {
+ recoverEnabled.value = phoneNumber.value.orEmpty().isNotEmpty() && selectedDialPlan.value?.countryCallingCode.orEmpty().isNotEmpty()
+ }
+ }
+
+ override fun onCleared() {
+ coreContext.postOnCoreThread { core ->
+ core.removeListener(coreListener)
+ accountCreator.removeListener(accountCreatorListener)
+ }
+
+ super.onCleared()
+ }
+
+ @UiThread
+ fun sendCode() {
+ coreContext.postOnCoreThread {
+ val dialPlan = selectedDialPlan.value
+ if (dialPlan == null) {
+ Log.e("$TAG No dial plan (country) selected!")
+ return@postOnCoreThread
+ }
+ val number = phoneNumber.value.orEmpty().trim()
+ val formattedPhoneNumber = dialPlan.formatPhoneNumber(number, false)
+ Log.i(
+ "$TAG Formatted phone number [$number] using dial plan [${dialPlan.country}] is [$formattedPhoneNumber]"
+ )
+
+ val message = coreContext.context.getString(
+ R.string.assistant_account_creation_sms_confirmation_explanation,
+ formattedPhoneNumber
+ )
+ normalizedPhoneNumber = formattedPhoneNumber
+ confirmationMessage.postValue(message)
+ normalizedPhoneNumberEvent.postValue(Event(formattedPhoneNumber))
+ }
+ }
+
+ @WorkerThread
+ fun requestSmsCode() {
+ operationInProgress.postValue(true)
+
+ coreContext.core.loadConfigFromXml(corePreferences.linphoneDefaultValuesPath)
+ accountCreator.domain = corePreferences.defaultDomain
+
+ val dialPlan = selectedDialPlan.value
+ if (dialPlan == null) {
+ Log.e("$TAG No dial plan (country) selected!")
+ return
+ }
+ val number = phoneNumber.value.orEmpty().trim()
+ val countryCallingCode = dialPlan.countryCallingCode
+ var result = AccountCreator.PhoneNumberStatus.fromInt(
+ accountCreator.setPhoneNumber(number, countryCallingCode)
+ )
+ if (result != AccountCreator.PhoneNumberStatus.Ok) {
+ Log.e(
+ "$TAG Error [$result] setting the phone number: $number with prefix: $countryCallingCode"
+ )
+ phoneNumberError.postValue(result.name)
+ operationInProgress.postValue(false)
+ return
+ }
+ Log.i("$TAG Phone number is ${accountCreator.phoneNumber}")
+
+ val result2 = accountCreator.setUsername(accountCreator.phoneNumber)
+ if (result2 != AccountCreator.UsernameStatus.Ok) {
+ Log.e(
+ "$TAG Error [${result2.name}] setting the username: ${accountCreator.phoneNumber}"
+ )
+ phoneNumberError.postValue(result2.name)
+ operationInProgress.postValue(false)
+ return
+ }
+ Log.i("$TAG Username is ${accountCreator.username}")
+
+ accountCreator.token = accountCreationToken
+ Log.i("$TAG Token is ${accountCreator.token}")
+
+ val status = accountCreator.recoverAccount()
+ Log.i("$TAG Recover account returned $status")
+ if (status != AccountCreator.Status.RequestOk) {
+ operationInProgress.postValue(false)
+ Log.e("$TAG Error doing recoverAccount [${status.name}]")
+ showFormattedRedToast(status.name, R.drawable.warning_circle)
+ }
+ }
+
+ @UiThread
+ fun validateCode() {
+ operationInProgress.value = true
+
+ coreContext.postOnCoreThread { core ->
+ val code =
+ "${smsCodeFirstDigit.value.orEmpty().trim()}${smsCodeSecondDigit.value.orEmpty().trim()}${smsCodeThirdDigit.value.orEmpty().trim()}${smsCodeLastDigit.value.orEmpty().trim()}"
+ accountCreator.activationCode = code
+ val status = accountCreator.loginLinphoneAccount()
+ Log.i("$TAG Code [$code] validation result is $status")
+ if (status != AccountCreator.Status.RequestOk) {
+ operationInProgress.postValue(false)
+ Log.e("$TAG Error doing loginLinphoneAccount [${status.name}]")
+ showFormattedRedToast(status.name, R.drawable.warning_circle)
+ }
+
+ // Reset code
+ smsCodeFirstDigit.postValue("")
+ smsCodeSecondDigit.postValue("")
+ smsCodeThirdDigit.postValue("")
+ smsCodeLastDigit.postValue("")
+ }
+ }
+
+ @WorkerThread
+ private fun createAccountAndAuthInfo(): Boolean {
+ val account = accountCreator.createAccountInCore()
+
+ if (account == null) {
+ Log.e("$TAG Account creator couldn't create account")
+ return false
+ }
+ coreContext.core.defaultAccount = account
+
+ val username = account.params.identityAddress?.username.orEmpty()
+ Log.i("$TAG Account created with username [$username]")
+ accountCreatedEvent.postValue(Event(username))
+ return true
+ }
+
+ @UiThread
+ fun startRecoveryProcess() {
+ coreContext.postOnCoreThread {
+ requestFlexiApiToken()
+ }
+ }
+
+ @WorkerThread
+ private fun requestFlexiApiToken() {
+ if (!coreContext.core.isPushNotificationAvailable) {
+ Log.e(
+ "$TAG Core says push notification aren't available, can't request a token from FlexiAPI"
+ )
+ onFlexiApiTokenRequestError()
+ return
+ }
+
+ operationInProgress.postValue(true)
+ recoverEnabled.postValue(false)
+
+ val pushConfig = coreContext.core.pushNotificationConfig
+ if (pushConfig != null) {
+ val provider = pushConfig.provider
+ val param = pushConfig.param
+ val prid = pushConfig.prid
+ if (provider.isNullOrEmpty() || param.isNullOrEmpty() || prid.isNullOrEmpty()) {
+ Log.e(
+ "$TAG At least one mandatory push information (provider [$provider], param [$param], prid [$prid]) is missing!"
+ )
+ onFlexiApiTokenRequestError()
+ return
+ }
+
+ // Request an auth token, will be sent by push
+ val request = accountManagerServices.createSendAccountCreationTokenByPushRequest(
+ provider,
+ param,
+ prid
+ )
+ request.addListener(accountManagerServicesListener)
+ request.submit()
+
+ val waitFor = TIME_TO_WAIT_FOR_PUSH_NOTIFICATION_WITH_ACCOUNT_CREATION_TOKEN
+ waitingForFlexiApiPushToken = true
+ waitForPushJob?.cancel()
+
+ Log.i("$TAG Waiting push with auth token for $waitFor ms")
+ waitForPushJob = viewModelScope.launch {
+ withContext(Dispatchers.IO) {
+ delay(waitFor.toLong())
+ }
+ withContext(Dispatchers.Main) {
+ if (waitingForFlexiApiPushToken) {
+ waitingForFlexiApiPushToken = false
+ Log.e("$TAG Auth token wasn't received by push in [$waitFor] ms")
+ onFlexiApiTokenRequestError()
+ }
+ }
+ }
+ } else {
+ Log.e("$TAG No push configuration object in Core, shouldn't happen!")
+ onFlexiApiTokenRequestError()
+ }
+ }
+
+ @WorkerThread
+ private fun onFlexiApiTokenRequestError() {
+ Log.e("$TAG Flexi API token request by push error!")
+ operationInProgress.postValue(false)
+ showRedToast(R.string.assistant_account_register_push_notification_not_received_error, R.drawable.warning_circle)
+ }
+}
diff --git a/app/src/main/res/drawable/password.xml b/app/src/main/res/drawable/password.xml
new file mode 100644
index 000000000..ee72b364f
--- /dev/null
+++ b/app/src/main/res/drawable/password.xml
@@ -0,0 +1,9 @@
+
+
+
diff --git a/app/src/main/res/layout/assistant_landing_fragment.xml b/app/src/main/res/layout/assistant_landing_fragment.xml
index fad8a01ed..df9e518db 100644
--- a/app/src/main/res/layout/assistant_landing_fragment.xml
+++ b/app/src/main/res/layout/assistant_landing_fragment.xml
@@ -214,7 +214,7 @@
android:onClick="@{qrCodeClickListener}"
android:layout_width="0dp"
android:layout_height="wrap_content"
- android:layout_marginTop="22dp"
+ android:layout_marginTop="24dp"
android:layout_marginStart="16dp"
android:layout_marginEnd="16dp"
android:paddingStart="20dp"
@@ -235,7 +235,7 @@
android:onClick="@{thirdPartySipAccountLoginClickListener}"
android:layout_width="0dp"
android:layout_height="wrap_content"
- android:layout_marginTop="16dp"
+ android:layout_marginTop="24dp"
android:layout_marginStart="16dp"
android:layout_marginEnd="16dp"
android:paddingStart="20dp"
diff --git a/app/src/main/res/layout/assistant_recover_account_fragment.xml b/app/src/main/res/layout/assistant_recover_account_fragment.xml
new file mode 100644
index 000000000..cda65e354
--- /dev/null
+++ b/app/src/main/res/layout/assistant_recover_account_fragment.xml
@@ -0,0 +1,181 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/app/src/main/res/layout/assistant_recover_phone_account_confirm_sms_code_fragment.xml b/app/src/main/res/layout/assistant_recover_phone_account_confirm_sms_code_fragment.xml
new file mode 100644
index 000000000..c37300640
--- /dev/null
+++ b/app/src/main/res/layout/assistant_recover_phone_account_confirm_sms_code_fragment.xml
@@ -0,0 +1,215 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/app/src/main/res/layout/assistant_recover_phone_account_fragment.xml b/app/src/main/res/layout/assistant_recover_phone_account_fragment.xml
new file mode 100644
index 000000000..8b0a602fc
--- /dev/null
+++ b/app/src/main/res/layout/assistant_recover_phone_account_fragment.xml
@@ -0,0 +1,212 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/app/src/main/res/navigation/assistant_nav_graph.xml b/app/src/main/res/navigation/assistant_nav_graph.xml
index 4e406f83e..3f3dec9f7 100644
--- a/app/src/main/res/navigation/assistant_nav_graph.xml
+++ b/app/src/main/res/navigation/assistant_nav_graph.xml
@@ -144,6 +144,14 @@
app:launchSingleTop="true"
app:popUpTo="@id/landingFragment"
app:popUpToInclusive="true"/>
+
@@ -169,4 +177,40 @@
app:enterAnim="@anim/slide_in"
app:popExitAnim="@anim/slide_out" />
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/app/src/main/res/values-fr/strings.xml b/app/src/main/res/values-fr/strings.xml
index 4aad13976..99bdc0fc4 100644
--- a/app/src/main/res/values-fr/strings.xml
+++ b/app/src/main/res/values-fr/strings.xml
@@ -133,6 +133,14 @@
Notifications : Pour vous informer quand vous recevez un message ou un appel.
Microphone : Pour permettre à vos correspondants de vous entendre.
Caméra : Pour capturer votre vidéo lors des appels et des conférences.
+ Mot de passe oublié
+ Choisissez comment récupérer votre compte.
+ Vous avez créé votre compte avec :
+ Un email
+ Un numéro de téléphone
+ Veuillez saisir le numéro de téléphone lié à votre compte, nous allons vous envoyer un code.
+ Envoyer le code
+ Les notifications push ne semblent pas être disponibles sur votre appareil. Celles-ci sont nécessaires à la récupération d’un compte sur l’application mobile avec un numéro de téléphone.
Contacts
diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml
index 7b2aebf4c..21665627a 100644
--- a/app/src/main/res/values/strings.xml
+++ b/app/src/main/res/values/strings.xml
@@ -27,7 +27,7 @@
😢
GNU General Public License v3.0
- © Belledonne Communications 2010-2024
+ © Belledonne Communications 2010-2025
linphone-android@belledonne-communications.com
https://linphone.org/contact
@@ -173,6 +173,14 @@
Post notifications: To be informed when you receive a message or a call.
Record audio: So your correspondent can hear you and to record voice messages.
Access camera: To capture video during video calls and conferences.
+ Password forgotten
+ Choose how to recover your account.
+ You created your account using:
+ An email
+ A phone number
+ Enter the phone number linked to your account, we\'ll send you a code.
+ Send the code
+ Push notifications do not seem to be available on your device, but they are mandatory for recovering a phone number account in the mobile app.
Contacts