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