mirror of
https://github.com/signalapp/Signal-Android.git
synced 2026-04-23 02:10:44 +01:00
Move all files to natural position.
This commit is contained in:
@@ -0,0 +1,121 @@
|
||||
package org.thoughtcrime.securesms.registration;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.annotation.WorkerThread;
|
||||
|
||||
import org.greenrobot.eventbus.EventBus;
|
||||
import org.greenrobot.eventbus.Subscribe;
|
||||
import org.greenrobot.eventbus.ThreadMode;
|
||||
import org.thoughtcrime.securesms.logging.Log;
|
||||
import org.whispersystems.libsignal.util.guava.Optional;
|
||||
import org.whispersystems.signalservice.api.SignalServiceAccountManager;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.util.Locale;
|
||||
import java.util.concurrent.CountDownLatch;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import java.util.concurrent.atomic.AtomicReference;
|
||||
|
||||
public final class PushChallengeRequest {
|
||||
|
||||
private static final String TAG = Log.tag(PushChallengeRequest.class);
|
||||
|
||||
/**
|
||||
* Requests a push challenge and waits for the response.
|
||||
* <p>
|
||||
* Blocks the current thread for up to {@param timeoutMs} milliseconds.
|
||||
*
|
||||
* @param accountManager Account manager to request the push from.
|
||||
* @param fcmToken Optional FCM token. If not present will return absent.
|
||||
* @param e164number Local number.
|
||||
* @param timeoutMs Timeout in milliseconds
|
||||
* @return Either returns a challenge, or absent.
|
||||
*/
|
||||
@WorkerThread
|
||||
public static Optional<String> getPushChallengeBlocking(@NonNull SignalServiceAccountManager accountManager,
|
||||
@NonNull Optional<String> fcmToken,
|
||||
@NonNull String e164number,
|
||||
long timeoutMs)
|
||||
{
|
||||
if (!fcmToken.isPresent()) {
|
||||
Log.w(TAG, "Push challenge not requested, as no FCM token was present");
|
||||
return Optional.absent();
|
||||
}
|
||||
|
||||
long startTime = System.currentTimeMillis();
|
||||
Log.i(TAG, "Requesting a push challenge");
|
||||
|
||||
Request request = new Request(accountManager, fcmToken.get(), e164number, timeoutMs);
|
||||
|
||||
Optional<String> challenge = request.requestAndReceiveChallengeBlocking();
|
||||
|
||||
long duration = System.currentTimeMillis() - startTime;
|
||||
|
||||
if (challenge.isPresent()) {
|
||||
Log.i(TAG, String.format(Locale.US, "Received a push challenge \"%s\" in %d ms", challenge.get(), duration));
|
||||
} else {
|
||||
Log.w(TAG, String.format(Locale.US, "Did not received a push challenge in %d ms", duration));
|
||||
}
|
||||
return challenge;
|
||||
}
|
||||
|
||||
public static void postChallengeResponse(@NonNull String challenge) {
|
||||
EventBus.getDefault().post(new PushChallengeEvent(challenge));
|
||||
}
|
||||
|
||||
public static class Request {
|
||||
|
||||
private final CountDownLatch latch;
|
||||
private final AtomicReference<String> challenge;
|
||||
private final SignalServiceAccountManager accountManager;
|
||||
private final String fcmToken;
|
||||
private final String e164number;
|
||||
private final long timeoutMs;
|
||||
|
||||
private Request(@NonNull SignalServiceAccountManager accountManager,
|
||||
@NonNull String fcmToken,
|
||||
@NonNull String e164number,
|
||||
long timeoutMs)
|
||||
{
|
||||
this.latch = new CountDownLatch(1);
|
||||
this.challenge = new AtomicReference<>();
|
||||
this.accountManager = accountManager;
|
||||
this.fcmToken = fcmToken;
|
||||
this.e164number = e164number;
|
||||
this.timeoutMs = timeoutMs;
|
||||
}
|
||||
|
||||
@WorkerThread
|
||||
private Optional<String> requestAndReceiveChallengeBlocking() {
|
||||
EventBus eventBus = EventBus.getDefault();
|
||||
|
||||
eventBus.register(this);
|
||||
try {
|
||||
accountManager.requestPushChallenge(fcmToken, e164number);
|
||||
|
||||
latch.await(timeoutMs, TimeUnit.MILLISECONDS);
|
||||
|
||||
return Optional.fromNullable(challenge.get());
|
||||
} catch (InterruptedException | IOException e) {
|
||||
Log.w(TAG, "Error getting push challenge", e);
|
||||
return Optional.absent();
|
||||
} finally {
|
||||
eventBus.unregister(this);
|
||||
}
|
||||
}
|
||||
|
||||
@Subscribe(threadMode = ThreadMode.POSTING)
|
||||
public void onChallengeEvent(@NonNull PushChallengeEvent pushChallengeEvent) {
|
||||
challenge.set(pushChallengeEvent.challenge);
|
||||
latch.countDown();
|
||||
}
|
||||
}
|
||||
|
||||
static class PushChallengeEvent {
|
||||
private final String challenge;
|
||||
|
||||
PushChallengeEvent(String challenge) {
|
||||
this.challenge = challenge;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
package org.thoughtcrime.securesms.registration;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
|
||||
public final class ReceivedSmsEvent {
|
||||
|
||||
private final @NonNull String code;
|
||||
|
||||
public ReceivedSmsEvent(@NonNull String code) {
|
||||
this.code = code;
|
||||
}
|
||||
|
||||
public @NonNull String getCode() {
|
||||
return code;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
package org.thoughtcrime.securesms.registration;
|
||||
|
||||
import android.content.BroadcastReceiver;
|
||||
import android.content.Context;
|
||||
import android.content.Intent;
|
||||
import android.content.IntentFilter;
|
||||
import android.os.Bundle;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.appcompat.app.AppCompatActivity;
|
||||
|
||||
import com.google.android.gms.auth.api.phone.SmsRetriever;
|
||||
import com.google.android.gms.common.api.CommonStatusCodes;
|
||||
import com.google.android.gms.common.api.Status;
|
||||
|
||||
import org.greenrobot.eventbus.EventBus;
|
||||
import org.thoughtcrime.securesms.R;
|
||||
import org.thoughtcrime.securesms.logging.Log;
|
||||
import org.thoughtcrime.securesms.service.VerificationCodeParser;
|
||||
import org.whispersystems.libsignal.util.guava.Optional;
|
||||
|
||||
public final class RegistrationNavigationActivity extends AppCompatActivity {
|
||||
|
||||
private static final String TAG = Log.tag(RegistrationNavigationActivity.class);
|
||||
|
||||
public static final String RE_REGISTRATION_EXTRA = "re_registration";
|
||||
|
||||
private SmsRetrieverReceiver smsRetrieverReceiver;
|
||||
|
||||
public static Intent newIntentForNewRegistration(@NonNull Context context) {
|
||||
Intent intent = new Intent(context, RegistrationNavigationActivity.class);
|
||||
intent.putExtra(RE_REGISTRATION_EXTRA, false);
|
||||
return intent;
|
||||
}
|
||||
|
||||
public static Intent newIntentForReRegistration(@NonNull Context context) {
|
||||
Intent intent = new Intent(context, RegistrationNavigationActivity.class);
|
||||
intent.putExtra(RE_REGISTRATION_EXTRA, true);
|
||||
return intent;
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void onCreate(Bundle savedInstanceState) {
|
||||
super.onCreate(savedInstanceState);
|
||||
setContentView(R.layout.activity_registration_navigation);
|
||||
initializeChallengeListener();
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void onDestroy() {
|
||||
super.onDestroy();
|
||||
shutdownChallengeListener();
|
||||
}
|
||||
|
||||
private void initializeChallengeListener() {
|
||||
smsRetrieverReceiver = new SmsRetrieverReceiver();
|
||||
|
||||
registerReceiver(smsRetrieverReceiver, new IntentFilter(SmsRetriever.SMS_RETRIEVED_ACTION));
|
||||
}
|
||||
|
||||
private void shutdownChallengeListener() {
|
||||
if (smsRetrieverReceiver != null) {
|
||||
unregisterReceiver(smsRetrieverReceiver);
|
||||
smsRetrieverReceiver = null;
|
||||
}
|
||||
}
|
||||
|
||||
private class SmsRetrieverReceiver extends BroadcastReceiver {
|
||||
|
||||
@Override
|
||||
public void onReceive(Context context, Intent intent) {
|
||||
Log.i(TAG, "SmsRetrieverReceiver received a broadcast...");
|
||||
|
||||
if (SmsRetriever.SMS_RETRIEVED_ACTION.equals(intent.getAction())) {
|
||||
Bundle extras = intent.getExtras();
|
||||
Status status = (Status) extras.get(SmsRetriever.EXTRA_STATUS);
|
||||
|
||||
switch (status.getStatusCode()) {
|
||||
case CommonStatusCodes.SUCCESS:
|
||||
Optional<String> code = VerificationCodeParser.parse(context, (String) extras.get(SmsRetriever.EXTRA_SMS_MESSAGE));
|
||||
if (code.isPresent()) {
|
||||
Log.i(TAG, "Received verification code.");
|
||||
handleVerificationCodeReceived(code.get());
|
||||
} else {
|
||||
Log.w(TAG, "Could not parse verification code.");
|
||||
}
|
||||
break;
|
||||
case CommonStatusCodes.TIMEOUT:
|
||||
Log.w(TAG, "Hit a timeout waiting for the SMS to arrive.");
|
||||
break;
|
||||
}
|
||||
} else {
|
||||
Log.w(TAG, "SmsRetrieverReceiver received the wrong action?");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void handleVerificationCodeReceived(@NonNull String code) {
|
||||
EventBus.getDefault().post(new ReceivedSmsEvent(code));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
package org.thoughtcrime.securesms.registration.fragments;
|
||||
|
||||
import android.app.Activity;
|
||||
import android.content.Context;
|
||||
import android.content.Intent;
|
||||
import android.os.Bundle;
|
||||
import android.view.View;
|
||||
import android.view.inputmethod.InputMethodManager;
|
||||
import android.widget.Toast;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.annotation.Nullable;
|
||||
import androidx.fragment.app.Fragment;
|
||||
import androidx.fragment.app.FragmentActivity;
|
||||
import androidx.lifecycle.SavedStateViewModelFactory;
|
||||
import androidx.lifecycle.ViewModelProviders;
|
||||
|
||||
import com.dd.CircularProgressButton;
|
||||
|
||||
import org.thoughtcrime.securesms.LogSubmitActivity;
|
||||
import org.thoughtcrime.securesms.R;
|
||||
import org.thoughtcrime.securesms.registration.viewmodel.RegistrationViewModel;
|
||||
|
||||
import static org.thoughtcrime.securesms.registration.RegistrationNavigationActivity.RE_REGISTRATION_EXTRA;
|
||||
|
||||
abstract class BaseRegistrationFragment extends Fragment {
|
||||
|
||||
private RegistrationViewModel model;
|
||||
|
||||
@Override
|
||||
public void onViewCreated(@NonNull View view, @Nullable Bundle savedInstanceState) {
|
||||
super.onViewCreated(view, savedInstanceState);
|
||||
|
||||
this.model = getRegistrationViewModel(requireActivity());
|
||||
}
|
||||
|
||||
protected @NonNull RegistrationViewModel getModel() {
|
||||
return model;
|
||||
}
|
||||
|
||||
protected boolean isReregister() {
|
||||
Activity activity = getActivity();
|
||||
|
||||
if (activity == null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return activity.getIntent().getBooleanExtra(RE_REGISTRATION_EXTRA, false);
|
||||
}
|
||||
|
||||
protected static RegistrationViewModel getRegistrationViewModel(@NonNull FragmentActivity activity) {
|
||||
SavedStateViewModelFactory savedStateViewModelFactory = new SavedStateViewModelFactory(activity.getApplication(), activity);
|
||||
|
||||
return ViewModelProviders.of(activity, savedStateViewModelFactory).get(RegistrationViewModel.class);
|
||||
}
|
||||
|
||||
protected static void setSpinning(@Nullable CircularProgressButton button) {
|
||||
if (button != null) {
|
||||
button.setClickable(false);
|
||||
button.setIndeterminateProgressMode(true);
|
||||
button.setProgress(50);
|
||||
}
|
||||
}
|
||||
|
||||
protected static void cancelSpinning(@Nullable CircularProgressButton button) {
|
||||
if (button != null) {
|
||||
button.setProgress(0);
|
||||
button.setIndeterminateProgressMode(false);
|
||||
button.setClickable(true);
|
||||
}
|
||||
}
|
||||
|
||||
protected static void hideKeyboard(@NonNull Context context, @NonNull View view) {
|
||||
InputMethodManager imm = (InputMethodManager) context.getSystemService(Activity.INPUT_METHOD_SERVICE);
|
||||
imm.hideSoftInputFromWindow(view.getWindowToken(), 0);
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets view up to allow log submitting after multiple taps.
|
||||
*/
|
||||
protected static void setDebugLogSubmitMultiTapView(@Nullable View view) {
|
||||
if (view == null) return;
|
||||
|
||||
view.setOnClickListener(new View.OnClickListener() {
|
||||
|
||||
private static final int DEBUG_TAP_TARGET = 8;
|
||||
private static final int DEBUG_TAP_ANNOUNCE = 4;
|
||||
|
||||
private int debugTapCounter;
|
||||
|
||||
@Override
|
||||
public void onClick(View v) {
|
||||
Context context = v.getContext();
|
||||
|
||||
debugTapCounter++;
|
||||
|
||||
if (debugTapCounter >= DEBUG_TAP_TARGET) {
|
||||
context.startActivity(new Intent(context, LogSubmitActivity.class));
|
||||
} else if (debugTapCounter >= DEBUG_TAP_ANNOUNCE) {
|
||||
int remaining = DEBUG_TAP_TARGET - debugTapCounter;
|
||||
|
||||
Toast.makeText(context, context.getResources().getQuantityString(R.plurals.RegistrationActivity_debug_log_hint, remaining, remaining), Toast.LENGTH_SHORT).show();
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
package org.thoughtcrime.securesms.registration.fragments;
|
||||
|
||||
import android.annotation.SuppressLint;
|
||||
import android.os.Bundle;
|
||||
import android.view.LayoutInflater;
|
||||
import android.view.View;
|
||||
import android.view.ViewGroup;
|
||||
import android.webkit.WebView;
|
||||
import android.webkit.WebViewClient;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.annotation.Nullable;
|
||||
import androidx.navigation.Navigation;
|
||||
|
||||
import org.thoughtcrime.securesms.R;
|
||||
|
||||
/**
|
||||
* Fragment that displays a Captcha in a WebView.
|
||||
*/
|
||||
public final class CaptchaFragment extends BaseRegistrationFragment {
|
||||
|
||||
@Override
|
||||
public View onCreateView(LayoutInflater inflater, ViewGroup container, Bundle savedInstanceState) {
|
||||
return inflater.inflate(R.layout.fragment_registration_captcha, container, false);
|
||||
}
|
||||
|
||||
@Override
|
||||
@SuppressLint("SetJavaScriptEnabled")
|
||||
public void onViewCreated(@NonNull View view, @Nullable Bundle savedInstanceState) {
|
||||
super.onViewCreated(view, savedInstanceState);
|
||||
|
||||
WebView webView = view.findViewById(R.id.registration_captcha_web_view);
|
||||
|
||||
webView.getSettings().setJavaScriptEnabled(true);
|
||||
webView.clearCache(true);
|
||||
|
||||
webView.setWebViewClient(new WebViewClient() {
|
||||
@Override
|
||||
public boolean shouldOverrideUrlLoading(WebView view, String url) {
|
||||
if (url != null && url.startsWith(RegistrationConstants.SIGNAL_CAPTCHA_SCHEME)) {
|
||||
handleToken(url.substring(RegistrationConstants.SIGNAL_CAPTCHA_SCHEME.length()));
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
});
|
||||
|
||||
webView.loadUrl(RegistrationConstants.SIGNAL_CAPTCHA_URL);
|
||||
}
|
||||
|
||||
private void handleToken(@NonNull String token) {
|
||||
getModel().onCaptchaResponse(token);
|
||||
|
||||
Navigation.findNavController(requireView()).navigate(CaptchaFragmentDirections.actionCaptchaComplete());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
package org.thoughtcrime.securesms.registration.fragments;
|
||||
|
||||
import android.os.Bundle;
|
||||
import android.text.Editable;
|
||||
import android.text.TextWatcher;
|
||||
import android.view.LayoutInflater;
|
||||
import android.view.View;
|
||||
import android.view.ViewGroup;
|
||||
import android.widget.EditText;
|
||||
import android.widget.ListView;
|
||||
import android.widget.SimpleAdapter;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.annotation.Nullable;
|
||||
import androidx.fragment.app.ListFragment;
|
||||
import androidx.loader.app.LoaderManager;
|
||||
import androidx.loader.content.Loader;
|
||||
import androidx.navigation.Navigation;
|
||||
|
||||
import org.thoughtcrime.securesms.R;
|
||||
import org.thoughtcrime.securesms.database.loaders.CountryListLoader;
|
||||
import org.thoughtcrime.securesms.registration.viewmodel.RegistrationViewModel;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.Map;
|
||||
|
||||
public final class CountryPickerFragment extends ListFragment implements LoaderManager.LoaderCallbacks<ArrayList<Map<String, String>>> {
|
||||
|
||||
private EditText countryFilter;
|
||||
private RegistrationViewModel model;
|
||||
|
||||
@Override
|
||||
public View onCreateView(LayoutInflater inflater, ViewGroup container, Bundle bundle) {
|
||||
return inflater.inflate(R.layout.fragment_registration_country_picker, container, false);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onViewCreated(@NonNull View view, @Nullable Bundle savedInstanceState) {
|
||||
super.onViewCreated(view, savedInstanceState);
|
||||
|
||||
model = BaseRegistrationFragment.getRegistrationViewModel(requireActivity());
|
||||
|
||||
countryFilter = view.findViewById(R.id.country_search);
|
||||
|
||||
countryFilter.addTextChangedListener(new FilterWatcher());
|
||||
LoaderManager.getInstance(this).initLoader(0, null, this).forceLoad();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onListItemClick(@NonNull ListView listView, @NonNull View view, int position, long id) {
|
||||
Map<String, String> item = (Map<String, String>) getListAdapter().getItem(position);
|
||||
|
||||
int countryCode = Integer.parseInt(item.get("country_code").replace("+", ""));
|
||||
String countryName = item.get("country_name");
|
||||
|
||||
model.onCountrySelected(countryName, countryCode);
|
||||
|
||||
Navigation.findNavController(view).navigate(CountryPickerFragmentDirections.actionCountrySelected());
|
||||
}
|
||||
|
||||
@Override
|
||||
public @NonNull Loader<ArrayList<Map<String, String>>> onCreateLoader(int id, @Nullable Bundle args) {
|
||||
return new CountryListLoader(getActivity());
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onLoadFinished(@NonNull Loader<ArrayList<Map<String, String>>> loader,
|
||||
@NonNull ArrayList<Map<String, String>> results)
|
||||
{
|
||||
String[] from = { "country_name", "country_code" };
|
||||
int[] to = { R.id.country_name, R.id.country_code };
|
||||
|
||||
setListAdapter(new SimpleAdapter(getActivity(), results, R.layout.country_list_item, from, to));
|
||||
|
||||
applyFilter(countryFilter.getText());
|
||||
}
|
||||
|
||||
private void applyFilter(@NonNull CharSequence text) {
|
||||
SimpleAdapter listAdapter = (SimpleAdapter) getListAdapter();
|
||||
|
||||
if (listAdapter != null) {
|
||||
listAdapter.getFilter().filter(text);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onLoaderReset(@NonNull Loader<ArrayList<Map<String, String>>> loader) {
|
||||
setListAdapter(null);
|
||||
}
|
||||
|
||||
private class FilterWatcher implements TextWatcher {
|
||||
|
||||
@Override
|
||||
public void afterTextChanged(Editable s) {
|
||||
applyFilter(s);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void beforeTextChanged(CharSequence s, int start, int count, int after) {
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onTextChanged(CharSequence s, int start, int before, int count) {
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,306 @@
|
||||
package org.thoughtcrime.securesms.registration.fragments;
|
||||
|
||||
import android.content.Intent;
|
||||
import android.net.Uri;
|
||||
import android.os.Build;
|
||||
import android.os.Bundle;
|
||||
import android.view.LayoutInflater;
|
||||
import android.view.View;
|
||||
import android.view.ViewGroup;
|
||||
import android.widget.ScrollView;
|
||||
import android.widget.TextView;
|
||||
import android.widget.Toast;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.annotation.Nullable;
|
||||
import androidx.appcompat.app.AlertDialog;
|
||||
import androidx.navigation.NavController;
|
||||
import androidx.navigation.Navigation;
|
||||
|
||||
import org.greenrobot.eventbus.EventBus;
|
||||
import org.greenrobot.eventbus.Subscribe;
|
||||
import org.greenrobot.eventbus.ThreadMode;
|
||||
import org.thoughtcrime.securesms.BuildConfig;
|
||||
import org.thoughtcrime.securesms.R;
|
||||
import org.thoughtcrime.securesms.components.registration.CallMeCountDownView;
|
||||
import org.thoughtcrime.securesms.components.registration.VerificationCodeView;
|
||||
import org.thoughtcrime.securesms.components.registration.VerificationPinKeyboard;
|
||||
import org.thoughtcrime.securesms.logging.Log;
|
||||
import org.thoughtcrime.securesms.registration.ReceivedSmsEvent;
|
||||
import org.thoughtcrime.securesms.registration.service.CodeVerificationRequest;
|
||||
import org.thoughtcrime.securesms.registration.service.RegistrationCodeRequest;
|
||||
import org.thoughtcrime.securesms.registration.service.RegistrationService;
|
||||
import org.thoughtcrime.securesms.registration.viewmodel.RegistrationViewModel;
|
||||
import org.thoughtcrime.securesms.util.concurrent.AssertedSuccessListener;
|
||||
import org.whispersystems.signalservice.internal.contacts.entities.TokenResponse;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.Collections;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
|
||||
public final class EnterCodeFragment extends BaseRegistrationFragment {
|
||||
|
||||
private static final String TAG = Log.tag(EnterCodeFragment.class);
|
||||
|
||||
private ScrollView scrollView;
|
||||
private TextView header;
|
||||
private VerificationCodeView verificationCodeView;
|
||||
private VerificationPinKeyboard keyboard;
|
||||
private CallMeCountDownView callMeCountDown;
|
||||
private View wrongNumber;
|
||||
private View noCodeReceivedHelp;
|
||||
private boolean autoCompleting;
|
||||
|
||||
@Override
|
||||
public View onCreateView(LayoutInflater inflater, ViewGroup container, Bundle savedInstanceState) {
|
||||
return inflater.inflate(R.layout.fragment_registration_enter_code, container, false);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onViewCreated(@NonNull View view, @Nullable Bundle savedInstanceState) {
|
||||
super.onViewCreated(view, savedInstanceState);
|
||||
|
||||
setDebugLogSubmitMultiTapView(view.findViewById(R.id.verify_header));
|
||||
|
||||
scrollView = view.findViewById(R.id.scroll_view);
|
||||
header = view.findViewById(R.id.verify_header);
|
||||
verificationCodeView = view.findViewById(R.id.code);
|
||||
keyboard = view.findViewById(R.id.keyboard);
|
||||
callMeCountDown = view.findViewById(R.id.call_me_count_down);
|
||||
wrongNumber = view.findViewById(R.id.wrong_number);
|
||||
noCodeReceivedHelp = view.findViewById(R.id.no_code);
|
||||
|
||||
connectKeyboard(verificationCodeView, keyboard);
|
||||
|
||||
setOnCodeFullyEnteredListener(verificationCodeView);
|
||||
|
||||
wrongNumber.setOnClickListener(v -> Navigation.findNavController(view).navigate(EnterCodeFragmentDirections.actionWrongNumber()));
|
||||
|
||||
callMeCountDown.setOnClickListener(v -> handlePhoneCallRequest());
|
||||
|
||||
callMeCountDown.setListener((v, remaining) -> {
|
||||
if (remaining <= 30) {
|
||||
scrollView.smoothScrollTo(0, v.getBottom());
|
||||
callMeCountDown.setListener(null);
|
||||
}
|
||||
});
|
||||
|
||||
noCodeReceivedHelp.setOnClickListener(v -> sendEmailToSupport());
|
||||
|
||||
getModel().getSuccessfulCodeRequestAttempts().observe(this, (attempts) -> {
|
||||
if (attempts >= 3) {
|
||||
noCodeReceivedHelp.setVisibility(View.VISIBLE);
|
||||
scrollView.postDelayed(() -> scrollView.smoothScrollTo(0, noCodeReceivedHelp.getBottom()), 15000);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private void setOnCodeFullyEnteredListener(VerificationCodeView verificationCodeView) {
|
||||
verificationCodeView.setOnCompleteListener(code -> {
|
||||
RegistrationViewModel model = getModel();
|
||||
|
||||
model.onVerificationCodeEntered(code);
|
||||
callMeCountDown.setVisibility(View.INVISIBLE);
|
||||
wrongNumber.setVisibility(View.INVISIBLE);
|
||||
keyboard.displayProgress();
|
||||
|
||||
RegistrationService registrationService = RegistrationService.getInstance(model.getNumber().getE164Number(), model.getRegistrationSecret());
|
||||
|
||||
registrationService.verifyAccount(requireActivity(), model.getFcmToken(), code, null, null, null,
|
||||
new CodeVerificationRequest.VerifyCallback() {
|
||||
|
||||
@Override
|
||||
public void onSuccessfulRegistration() {
|
||||
keyboard.displaySuccess().addListener(new AssertedSuccessListener<Boolean>() {
|
||||
@Override
|
||||
public void onSuccess(Boolean result) {
|
||||
handleSuccessfulRegistration();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onIncorrectRegistrationLockPin(long timeRemaining, String storageCredentials) {
|
||||
model.setStorageCredentials(storageCredentials);
|
||||
keyboard.displayLocked().addListener(new AssertedSuccessListener<Boolean>() {
|
||||
@Override
|
||||
public void onSuccess(Boolean r) {
|
||||
Navigation.findNavController(requireView())
|
||||
.navigate(EnterCodeFragmentDirections.actionRequireRegistrationLockPin(timeRemaining));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onIncorrectKbsRegistrationLockPin(@NonNull TokenResponse triesRemaining) {
|
||||
// Unexpected, because at this point, no pin has been provided by the user.
|
||||
throw new AssertionError();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onTooManyAttempts() {
|
||||
keyboard.displayFailure().addListener(new AssertedSuccessListener<Boolean>() {
|
||||
@Override
|
||||
public void onSuccess(Boolean r) {
|
||||
new AlertDialog.Builder(requireContext())
|
||||
.setTitle(R.string.RegistrationActivity_too_many_attempts)
|
||||
.setMessage(R.string.RegistrationActivity_you_have_made_too_many_incorrect_registration_lock_pin_attempts_please_try_again_in_a_day)
|
||||
.setPositiveButton(android.R.string.ok, (dialog, which) -> {
|
||||
callMeCountDown.setVisibility(View.VISIBLE);
|
||||
wrongNumber.setVisibility(View.VISIBLE);
|
||||
verificationCodeView.clear();
|
||||
keyboard.displayKeyboard();
|
||||
})
|
||||
.show();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onError() {
|
||||
Toast.makeText(requireContext(), R.string.RegistrationActivity_error_connecting_to_service, Toast.LENGTH_LONG).show();
|
||||
keyboard.displayFailure().addListener(new AssertedSuccessListener<Boolean>() {
|
||||
@Override
|
||||
public void onSuccess(Boolean result) {
|
||||
callMeCountDown.setVisibility(View.VISIBLE);
|
||||
wrongNumber.setVisibility(View.VISIBLE);
|
||||
verificationCodeView.clear();
|
||||
keyboard.displayKeyboard();
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
private void handleSuccessfulRegistration() {
|
||||
Navigation.findNavController(requireView()).navigate(EnterCodeFragmentDirections.actionSuccessfulRegistration());
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onStart() {
|
||||
super.onStart();
|
||||
EventBus.getDefault().register(this);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onStop() {
|
||||
super.onStop();
|
||||
EventBus.getDefault().unregister(this);
|
||||
}
|
||||
|
||||
@Subscribe(threadMode = ThreadMode.MAIN)
|
||||
public void onVerificationCodeReceived(@NonNull ReceivedSmsEvent event) {
|
||||
verificationCodeView.clear();
|
||||
|
||||
List<Integer> parsedCode = convertVerificationCodeToDigits(event.getCode());
|
||||
|
||||
autoCompleting = true;
|
||||
|
||||
final int size = parsedCode.size();
|
||||
|
||||
for (int i = 0; i < size; i++) {
|
||||
final int index = i;
|
||||
verificationCodeView.postDelayed(() -> {
|
||||
verificationCodeView.append(parsedCode.get(index));
|
||||
if (index == size - 1) {
|
||||
autoCompleting = false;
|
||||
}
|
||||
}, i * 200);
|
||||
}
|
||||
}
|
||||
|
||||
private static List<Integer> convertVerificationCodeToDigits(@Nullable String code) {
|
||||
if (code == null || code.length() != 6) {
|
||||
return Collections.emptyList();
|
||||
}
|
||||
|
||||
List<Integer> result = new ArrayList<>(code.length());
|
||||
|
||||
try {
|
||||
for (int i = 0; i < code.length(); i++) {
|
||||
result.add(Integer.parseInt(Character.toString(code.charAt(i))));
|
||||
}
|
||||
} catch (NumberFormatException e) {
|
||||
Log.w(TAG, "Failed to convert code into digits.", e);
|
||||
return Collections.emptyList();
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
private void handlePhoneCallRequest() {
|
||||
callMeCountDown.startCountDown(RegistrationConstants.SUBSEQUENT_CALL_AVAILABLE_AFTER);
|
||||
|
||||
RegistrationViewModel model = getModel();
|
||||
String captcha = model.getCaptchaToken();
|
||||
model.clearCaptchaResponse();
|
||||
|
||||
NavController navController = Navigation.findNavController(callMeCountDown);
|
||||
|
||||
RegistrationService registrationService = RegistrationService.getInstance(model.getNumber().getE164Number(), model.getRegistrationSecret());
|
||||
|
||||
registrationService.requestVerificationCode(requireActivity(), RegistrationCodeRequest.Mode.PHONE_CALL, captcha,
|
||||
new RegistrationCodeRequest.SmsVerificationCodeCallback() {
|
||||
|
||||
@Override
|
||||
public void onNeedCaptcha() {
|
||||
navController.navigate(EnterCodeFragmentDirections.actionRequestCaptcha());
|
||||
}
|
||||
|
||||
@Override
|
||||
public void requestSent(@Nullable String fcmToken) {
|
||||
model.setFcmToken(fcmToken);
|
||||
model.markASuccessfulAttempt();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onError() {
|
||||
Toast.makeText(requireContext(), R.string.RegistrationActivity_unable_to_connect_to_service, Toast.LENGTH_LONG).show();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private void connectKeyboard(VerificationCodeView verificationCodeView, VerificationPinKeyboard keyboard) {
|
||||
keyboard.setOnKeyPressListener(key -> {
|
||||
if (!autoCompleting) {
|
||||
if (key >= 0) {
|
||||
verificationCodeView.append(key);
|
||||
} else {
|
||||
verificationCodeView.delete();
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onResume() {
|
||||
super.onResume();
|
||||
|
||||
getModel().getLiveNumber().observe(this, (s) -> header.setText(requireContext().getString(R.string.RegistrationActivity_enter_the_code_we_sent_to_s, s.getFullFormattedNumber())));
|
||||
|
||||
callMeCountDown.startCountDown(RegistrationConstants.FIRST_CALL_AVAILABLE_AFTER);
|
||||
}
|
||||
|
||||
private void sendEmailToSupport() {
|
||||
Intent intent = new Intent(Intent.ACTION_SENDTO);
|
||||
intent.setData(Uri.parse("mailto:"));
|
||||
intent.putExtra(Intent.EXTRA_EMAIL, new String[]{ getString(R.string.RegistrationActivity_support_email) });
|
||||
intent.putExtra(Intent.EXTRA_SUBJECT, getString(R.string.RegistrationActivity_code_support_subject));
|
||||
intent.putExtra(Intent.EXTRA_TEXT, getString(R.string.RegistrationActivity_code_support_body,
|
||||
getDevice(),
|
||||
getAndroidVersion(),
|
||||
BuildConfig.VERSION_NAME,
|
||||
Locale.getDefault()));
|
||||
startActivity(intent);
|
||||
}
|
||||
|
||||
private static String getDevice() {
|
||||
return String.format("%s %s (%s)", Build.MANUFACTURER, Build.MODEL, Build.PRODUCT);
|
||||
}
|
||||
|
||||
private static String getAndroidVersion() {
|
||||
return String.format("%s (%s, %s)", Build.VERSION.RELEASE, Build.VERSION.INCREMENTAL, Build.DISPLAY);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,420 @@
|
||||
package org.thoughtcrime.securesms.registration.fragments;
|
||||
|
||||
import android.content.Context;
|
||||
import android.os.Bundle;
|
||||
import android.text.Editable;
|
||||
import android.text.TextUtils;
|
||||
import android.text.TextWatcher;
|
||||
import android.view.KeyEvent;
|
||||
import android.view.LayoutInflater;
|
||||
import android.view.MotionEvent;
|
||||
import android.view.View;
|
||||
import android.view.ViewGroup;
|
||||
import android.view.inputmethod.EditorInfo;
|
||||
import android.widget.ArrayAdapter;
|
||||
import android.widget.EditText;
|
||||
import android.widget.ScrollView;
|
||||
import android.widget.Spinner;
|
||||
import android.widget.Toast;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.annotation.Nullable;
|
||||
import androidx.appcompat.app.AlertDialog;
|
||||
import androidx.navigation.NavController;
|
||||
import androidx.navigation.Navigation;
|
||||
|
||||
import com.dd.CircularProgressButton;
|
||||
import com.google.android.gms.auth.api.phone.SmsRetriever;
|
||||
import com.google.android.gms.auth.api.phone.SmsRetrieverClient;
|
||||
import com.google.android.gms.common.ConnectionResult;
|
||||
import com.google.android.gms.common.GoogleApiAvailability;
|
||||
import com.google.android.gms.tasks.Task;
|
||||
import com.google.i18n.phonenumbers.AsYouTypeFormatter;
|
||||
import com.google.i18n.phonenumbers.PhoneNumberUtil;
|
||||
|
||||
import org.thoughtcrime.securesms.R;
|
||||
import org.thoughtcrime.securesms.components.LabeledEditText;
|
||||
import org.thoughtcrime.securesms.logging.Log;
|
||||
import org.thoughtcrime.securesms.registration.service.RegistrationCodeRequest;
|
||||
import org.thoughtcrime.securesms.registration.service.RegistrationService;
|
||||
import org.thoughtcrime.securesms.registration.viewmodel.NumberViewState;
|
||||
import org.thoughtcrime.securesms.registration.viewmodel.RegistrationViewModel;
|
||||
import org.thoughtcrime.securesms.util.Dialogs;
|
||||
import org.thoughtcrime.securesms.util.PlayServicesUtil;
|
||||
|
||||
public final class EnterPhoneNumberFragment extends BaseRegistrationFragment {
|
||||
|
||||
private static final String TAG = Log.tag(EnterPhoneNumberFragment.class);
|
||||
|
||||
private LabeledEditText countryCode;
|
||||
private LabeledEditText number;
|
||||
private ArrayAdapter<String> countrySpinnerAdapter;
|
||||
private AsYouTypeFormatter countryFormatter;
|
||||
private CircularProgressButton register;
|
||||
private Spinner countrySpinner;
|
||||
private View cancel;
|
||||
private ScrollView scrollView;
|
||||
|
||||
@Override
|
||||
public View onCreateView(LayoutInflater inflater, ViewGroup container,
|
||||
Bundle savedInstanceState) {
|
||||
return inflater.inflate(R.layout.fragment_registration_enter_phone_number, container, false);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onViewCreated(@NonNull View view, @Nullable Bundle savedInstanceState) {
|
||||
super.onViewCreated(view, savedInstanceState);
|
||||
|
||||
setDebugLogSubmitMultiTapView(view.findViewById(R.id.verify_header));
|
||||
|
||||
countryCode = view.findViewById(R.id.country_code);
|
||||
number = view.findViewById(R.id.number);
|
||||
countrySpinner = view.findViewById(R.id.country_spinner);
|
||||
cancel = view.findViewById(R.id.cancel_button);
|
||||
scrollView = view.findViewById(R.id.scroll_view);
|
||||
register = view.findViewById(R.id.registerButton);
|
||||
|
||||
initializeSpinner(countrySpinner);
|
||||
|
||||
setUpNumberInput();
|
||||
|
||||
register.setOnClickListener(v -> handleRegister(requireContext()));
|
||||
|
||||
if (isReregister()) {
|
||||
cancel.setVisibility(View.VISIBLE);
|
||||
cancel.setOnClickListener(v -> Navigation.findNavController(v).navigateUp());
|
||||
} else {
|
||||
cancel.setVisibility(View.GONE);
|
||||
}
|
||||
|
||||
RegistrationViewModel model = getModel();
|
||||
NumberViewState number = model.getNumber();
|
||||
|
||||
initNumber(number);
|
||||
|
||||
countryCode.getInput().addTextChangedListener(new CountryCodeChangedListener());
|
||||
|
||||
if (model.hasCaptchaToken()) {
|
||||
handleRegister(requireContext());
|
||||
}
|
||||
|
||||
countryCode.getInput().setImeOptions(EditorInfo.IME_ACTION_NEXT);
|
||||
}
|
||||
|
||||
private void setUpNumberInput() {
|
||||
EditText numberInput = number.getInput();
|
||||
|
||||
numberInput.addTextChangedListener(new NumberChangedListener());
|
||||
|
||||
number.setOnFocusChangeListener((v, hasFocus) -> {
|
||||
if (hasFocus) {
|
||||
scrollView.postDelayed(() -> scrollView.smoothScrollTo(0, register.getBottom()), 250);
|
||||
}
|
||||
});
|
||||
|
||||
numberInput.setImeOptions(EditorInfo.IME_ACTION_DONE);
|
||||
numberInput.setOnEditorActionListener((v, actionId, event) -> {
|
||||
if (actionId == EditorInfo.IME_ACTION_DONE) {
|
||||
hideKeyboard(requireContext(), v);
|
||||
handleRegister(requireContext());
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
});
|
||||
}
|
||||
|
||||
private void handleRegister(@NonNull Context context) {
|
||||
if (TextUtils.isEmpty(countryCode.getText())) {
|
||||
Toast.makeText(context, getString(R.string.RegistrationActivity_you_must_specify_your_country_code), Toast.LENGTH_LONG).show();
|
||||
return;
|
||||
}
|
||||
|
||||
if (TextUtils.isEmpty(this.number.getText())) {
|
||||
Toast.makeText(context, getString(R.string.RegistrationActivity_you_must_specify_your_phone_number), Toast.LENGTH_LONG).show();
|
||||
return;
|
||||
}
|
||||
|
||||
final NumberViewState number = getModel().getNumber();
|
||||
final String e164number = number.getE164Number();
|
||||
|
||||
if (!number.isValid()) {
|
||||
Dialogs.showAlertDialog(context,
|
||||
getString(R.string.RegistrationActivity_invalid_number),
|
||||
String.format(getString(R.string.RegistrationActivity_the_number_you_specified_s_is_invalid), e164number));
|
||||
return;
|
||||
}
|
||||
|
||||
PlayServicesUtil.PlayServicesStatus fcmStatus = PlayServicesUtil.getPlayServicesStatus(context);
|
||||
|
||||
if (fcmStatus == PlayServicesUtil.PlayServicesStatus.SUCCESS) {
|
||||
handleRequestVerification(context, e164number, true);
|
||||
} else if (fcmStatus == PlayServicesUtil.PlayServicesStatus.MISSING) {
|
||||
handlePromptForNoPlayServices(context, e164number);
|
||||
} else if (fcmStatus == PlayServicesUtil.PlayServicesStatus.NEEDS_UPDATE) {
|
||||
GoogleApiAvailability.getInstance().getErrorDialog(requireActivity(), ConnectionResult.SERVICE_VERSION_UPDATE_REQUIRED, 0).show();
|
||||
} else {
|
||||
Dialogs.showAlertDialog(context, getString(R.string.RegistrationActivity_play_services_error),
|
||||
getString(R.string.RegistrationActivity_google_play_services_is_updating_or_unavailable));
|
||||
}
|
||||
}
|
||||
|
||||
private void handleRequestVerification(@NonNull Context context, @NonNull String e164number, boolean fcmSupported) {
|
||||
setSpinning(register);
|
||||
disableAllEntries();
|
||||
|
||||
if (fcmSupported) {
|
||||
SmsRetrieverClient client = SmsRetriever.getClient(context);
|
||||
Task<Void> task = client.startSmsRetriever();
|
||||
|
||||
task.addOnSuccessListener(none -> {
|
||||
Log.i(TAG, "Successfully registered SMS listener.");
|
||||
requestVerificationCode(e164number, RegistrationCodeRequest.Mode.SMS_FCM_WITH_LISTENER);
|
||||
});
|
||||
|
||||
task.addOnFailureListener(e -> {
|
||||
Log.w(TAG, "Failed to register SMS listener.", e);
|
||||
requestVerificationCode(e164number, RegistrationCodeRequest.Mode.SMS_FCM_NO_LISTENER);
|
||||
});
|
||||
} else {
|
||||
requestVerificationCode(e164number, RegistrationCodeRequest.Mode.SMS_NO_FCM);
|
||||
}
|
||||
}
|
||||
|
||||
private void disableAllEntries() {
|
||||
countryCode.setEnabled(false);
|
||||
number.setEnabled(false);
|
||||
countrySpinner.setEnabled(false);
|
||||
cancel.setVisibility(View.GONE);
|
||||
}
|
||||
|
||||
private void enableAllEntries() {
|
||||
countryCode.setEnabled(true);
|
||||
number.setEnabled(true);
|
||||
countrySpinner.setEnabled(true);
|
||||
if (isReregister()) {
|
||||
cancel.setVisibility(View.VISIBLE);
|
||||
}
|
||||
}
|
||||
|
||||
private void requestVerificationCode(String e164number, @NonNull RegistrationCodeRequest.Mode mode) {
|
||||
RegistrationViewModel model = getModel();
|
||||
String captcha = model.getCaptchaToken();
|
||||
model.clearCaptchaResponse();
|
||||
|
||||
NavController navController = Navigation.findNavController(register);
|
||||
|
||||
if (!model.getRequestLimiter().canRequest(mode, e164number, System.currentTimeMillis())) {
|
||||
Log.i(TAG, "Local rate limited");
|
||||
navController.navigate(EnterPhoneNumberFragmentDirections.actionEnterVerificationCode());
|
||||
cancelSpinning(register);
|
||||
enableAllEntries();
|
||||
return;
|
||||
}
|
||||
|
||||
RegistrationService registrationService = RegistrationService.getInstance(e164number, model.getRegistrationSecret());
|
||||
|
||||
registrationService.requestVerificationCode(requireActivity(), mode, captcha,
|
||||
new RegistrationCodeRequest.SmsVerificationCodeCallback() {
|
||||
|
||||
@Override
|
||||
public void onNeedCaptcha() {
|
||||
if (getContext() == null) {
|
||||
Log.i(TAG, "Got onNeedCaptcha response, but fragment is no longer attached.");
|
||||
return;
|
||||
}
|
||||
navController.navigate(EnterPhoneNumberFragmentDirections.actionRequestCaptcha());
|
||||
cancelSpinning(register);
|
||||
enableAllEntries();
|
||||
model.getRequestLimiter().onUnsuccessfulRequest();
|
||||
model.updateLimiter();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void requestSent(@Nullable String fcmToken) {
|
||||
if (getContext() == null) {
|
||||
Log.i(TAG, "Got requestSent response, but fragment is no longer attached.");
|
||||
return;
|
||||
}
|
||||
model.setFcmToken(fcmToken);
|
||||
model.markASuccessfulAttempt();
|
||||
navController.navigate(EnterPhoneNumberFragmentDirections.actionEnterVerificationCode());
|
||||
cancelSpinning(register);
|
||||
enableAllEntries();
|
||||
model.getRequestLimiter().onSuccessfulRequest(mode, e164number, System.currentTimeMillis());
|
||||
model.updateLimiter();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onError() {
|
||||
Toast.makeText(register.getContext(), R.string.RegistrationActivity_unable_to_connect_to_service, Toast.LENGTH_LONG).show();
|
||||
cancelSpinning(register);
|
||||
enableAllEntries();
|
||||
model.getRequestLimiter().onUnsuccessfulRequest();
|
||||
model.updateLimiter();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private void initializeSpinner(Spinner countrySpinner) {
|
||||
countrySpinnerAdapter = new ArrayAdapter<>(requireContext(), android.R.layout.simple_spinner_item);
|
||||
countrySpinnerAdapter.setDropDownViewResource(android.R.layout.simple_spinner_dropdown_item);
|
||||
|
||||
setCountryDisplay(getString(R.string.RegistrationActivity_select_your_country));
|
||||
|
||||
countrySpinner.setAdapter(countrySpinnerAdapter);
|
||||
countrySpinner.setOnTouchListener((view, event) -> {
|
||||
if (event.getAction() == MotionEvent.ACTION_UP) {
|
||||
pickCountry(view);
|
||||
}
|
||||
return true;
|
||||
});
|
||||
countrySpinner.setOnKeyListener((view, keyCode, event) -> {
|
||||
if (keyCode == KeyEvent.KEYCODE_DPAD_CENTER && event.getAction() == KeyEvent.ACTION_UP) {
|
||||
pickCountry(view);
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
});
|
||||
}
|
||||
|
||||
private void pickCountry(@NonNull View view) {
|
||||
Navigation.findNavController(view).navigate(R.id.action_pickCountry);
|
||||
}
|
||||
|
||||
private void initNumber(@NonNull NumberViewState numberViewState) {
|
||||
int countryCode = numberViewState.getCountryCode();
|
||||
long number = numberViewState.getNationalNumber();
|
||||
String regionDisplayName = numberViewState.getCountryDisplayName();
|
||||
|
||||
this.countryCode.setText(String.valueOf(countryCode));
|
||||
|
||||
setCountryDisplay(regionDisplayName);
|
||||
|
||||
String regionCode = PhoneNumberUtil.getInstance().getRegionCodeForCountryCode(countryCode);
|
||||
setCountryFormatter(regionCode);
|
||||
|
||||
if (number != 0) {
|
||||
this.number.setText(String.valueOf(number));
|
||||
}
|
||||
}
|
||||
|
||||
private void setCountryDisplay(String regionDisplayName) {
|
||||
countrySpinnerAdapter.clear();
|
||||
if (regionDisplayName == null) {
|
||||
countrySpinnerAdapter.add(getString(R.string.RegistrationActivity_select_your_country));
|
||||
} else {
|
||||
countrySpinnerAdapter.add(regionDisplayName);
|
||||
}
|
||||
}
|
||||
|
||||
private class CountryCodeChangedListener implements TextWatcher {
|
||||
@Override
|
||||
public void afterTextChanged(Editable s) {
|
||||
if (TextUtils.isEmpty(s) || !TextUtils.isDigitsOnly(s)) {
|
||||
setCountryDisplay(null);
|
||||
countryFormatter = null;
|
||||
return;
|
||||
}
|
||||
|
||||
int countryCode = Integer.parseInt(s.toString());
|
||||
String regionCode = PhoneNumberUtil.getInstance().getRegionCodeForCountryCode(countryCode);
|
||||
|
||||
setCountryFormatter(regionCode);
|
||||
|
||||
if (!TextUtils.isEmpty(regionCode) && !regionCode.equals("ZZ")) {
|
||||
number.requestFocus();
|
||||
|
||||
int numberLength = number.getText().length();
|
||||
number.getInput().setSelection(numberLength, numberLength);
|
||||
}
|
||||
|
||||
RegistrationViewModel model = getModel();
|
||||
|
||||
model.onCountrySelected(null, countryCode);
|
||||
setCountryDisplay(model.getNumber().getCountryDisplayName());
|
||||
}
|
||||
|
||||
@Override
|
||||
public void beforeTextChanged(CharSequence s, int start, int count, int after) {
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onTextChanged(CharSequence s, int start, int before, int count) {
|
||||
}
|
||||
}
|
||||
|
||||
private class NumberChangedListener implements TextWatcher {
|
||||
|
||||
@Override
|
||||
public void afterTextChanged(Editable s) {
|
||||
Long number = reformatText(s);
|
||||
|
||||
if (number == null) return;
|
||||
|
||||
RegistrationViewModel model = getModel();
|
||||
|
||||
model.setNationalNumber(number);
|
||||
|
||||
setCountryDisplay(model.getNumber().getCountryDisplayName());
|
||||
}
|
||||
|
||||
@Override
|
||||
public void beforeTextChanged(CharSequence s, int start, int count, int after) {
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onTextChanged(CharSequence s, int start, int before, int count) {
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
private Long reformatText(Editable s) {
|
||||
if (countryFormatter == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (TextUtils.isEmpty(s)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
countryFormatter.clear();
|
||||
|
||||
String formattedNumber = null;
|
||||
StringBuilder justDigits = new StringBuilder();
|
||||
|
||||
for (int i = 0; i < s.length(); i++) {
|
||||
char c = s.charAt(i);
|
||||
if (Character.isDigit(c)) {
|
||||
formattedNumber = countryFormatter.inputDigit(c);
|
||||
justDigits.append(c);
|
||||
}
|
||||
}
|
||||
|
||||
if (formattedNumber != null && !s.toString().equals(formattedNumber)) {
|
||||
s.replace(0, s.length(), formattedNumber);
|
||||
}
|
||||
|
||||
if (justDigits.length() == 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return Long.parseLong(justDigits.toString());
|
||||
}
|
||||
|
||||
private void setCountryFormatter(@Nullable String regionCode) {
|
||||
PhoneNumberUtil util = PhoneNumberUtil.getInstance();
|
||||
|
||||
countryFormatter = regionCode != null ? util.getAsYouTypeFormatter(regionCode) : null;
|
||||
|
||||
reformatText(number.getText());
|
||||
}
|
||||
|
||||
private void handlePromptForNoPlayServices(@NonNull Context context, @NonNull String e164number) {
|
||||
new AlertDialog.Builder(context)
|
||||
.setTitle(R.string.RegistrationActivity_missing_google_play_services)
|
||||
.setMessage(R.string.RegistrationActivity_this_device_is_missing_google_play_services)
|
||||
.setPositiveButton(R.string.RegistrationActivity_i_understand, (dialog1, which) -> handleRequestVerification(context, e164number, false))
|
||||
.setNegativeButton(android.R.string.cancel, null)
|
||||
.show();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
package org.thoughtcrime.securesms.registration.fragments;
|
||||
|
||||
import android.content.Context;
|
||||
import android.content.Intent;
|
||||
import android.os.Bundle;
|
||||
import android.view.LayoutInflater;
|
||||
import android.view.View;
|
||||
import android.view.ViewGroup;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.annotation.Nullable;
|
||||
import androidx.fragment.app.FragmentActivity;
|
||||
import androidx.navigation.ActivityNavigator;
|
||||
|
||||
import org.thoughtcrime.securesms.CreateProfileActivity;
|
||||
import org.thoughtcrime.securesms.MainActivity;
|
||||
import org.thoughtcrime.securesms.R;
|
||||
|
||||
public final class RegistrationCompleteFragment extends BaseRegistrationFragment {
|
||||
|
||||
@Override
|
||||
public View onCreateView(LayoutInflater inflater, ViewGroup container,
|
||||
Bundle savedInstanceState) {
|
||||
return inflater.inflate(R.layout.fragment_registration_blank, container, false);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onViewCreated(@NonNull View view, @Nullable Bundle savedInstanceState) {
|
||||
super.onViewCreated(view, savedInstanceState);
|
||||
|
||||
FragmentActivity activity = requireActivity();
|
||||
|
||||
if (!isReregister()) {
|
||||
// TODO [greyson] Navigation
|
||||
activity.startActivity(getRoutedIntent(activity, CreateProfileActivity.class, new Intent(activity, MainActivity.class)));
|
||||
}
|
||||
|
||||
activity.finish();
|
||||
ActivityNavigator.applyPopAnimationsToPendingTransition(activity);
|
||||
}
|
||||
|
||||
private static Intent getRoutedIntent(@NonNull Context context, Class<?> destination, @Nullable Intent nextIntent) {
|
||||
final Intent intent = new Intent(context, destination);
|
||||
if (nextIntent != null) intent.putExtra("next_intent", nextIntent);
|
||||
return intent;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
package org.thoughtcrime.securesms.registration.fragments;
|
||||
|
||||
final class RegistrationConstants {
|
||||
|
||||
private RegistrationConstants() {
|
||||
}
|
||||
|
||||
static final int FIRST_CALL_AVAILABLE_AFTER = 64;
|
||||
static final int SUBSEQUENT_CALL_AVAILABLE_AFTER = 300;
|
||||
static final String TERMS_AND_CONDITIONS_URL = "https://signal.org/legal";
|
||||
|
||||
static final String SIGNAL_CAPTCHA_URL = "https://signalcaptchas.org/registration/generate.html";
|
||||
static final String SIGNAL_CAPTCHA_SCHEME = "signalcaptcha://";
|
||||
|
||||
}
|
||||
@@ -0,0 +1,214 @@
|
||||
package org.thoughtcrime.securesms.registration.fragments;
|
||||
|
||||
import android.os.Bundle;
|
||||
import android.text.Editable;
|
||||
import android.text.TextUtils;
|
||||
import android.text.TextWatcher;
|
||||
import android.view.LayoutInflater;
|
||||
import android.view.View;
|
||||
import android.view.ViewGroup;
|
||||
import android.view.inputmethod.EditorInfo;
|
||||
import android.widget.EditText;
|
||||
import android.widget.Toast;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.annotation.Nullable;
|
||||
import androidx.appcompat.app.AlertDialog;
|
||||
import androidx.navigation.Navigation;
|
||||
|
||||
import com.dd.CircularProgressButton;
|
||||
|
||||
import org.thoughtcrime.securesms.R;
|
||||
import org.thoughtcrime.securesms.logging.Log;
|
||||
import org.thoughtcrime.securesms.registration.service.CodeVerificationRequest;
|
||||
import org.thoughtcrime.securesms.registration.service.RegistrationService;
|
||||
import org.thoughtcrime.securesms.registration.viewmodel.RegistrationViewModel;
|
||||
import org.thoughtcrime.securesms.util.concurrent.SimpleTask;
|
||||
import org.whispersystems.signalservice.internal.contacts.entities.TokenResponse;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
|
||||
public final class RegistrationLockFragment extends BaseRegistrationFragment {
|
||||
|
||||
private static final String TAG = Log.tag(RegistrationLockFragment.class);
|
||||
|
||||
private EditText pinEntry;
|
||||
private CircularProgressButton pinButton;
|
||||
private long timeRemaining;
|
||||
|
||||
@Override
|
||||
public View onCreateView(LayoutInflater inflater, ViewGroup container,
|
||||
Bundle savedInstanceState) {
|
||||
return inflater.inflate(R.layout.fragment_registration_lock, container, false);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onViewCreated(@NonNull View view, @Nullable Bundle savedInstanceState) {
|
||||
super.onViewCreated(view, savedInstanceState);
|
||||
|
||||
setDebugLogSubmitMultiTapView(view.findViewById(R.id.verify_header));
|
||||
|
||||
pinEntry = view.findViewById(R.id.pin);
|
||||
pinButton = view.findViewById(R.id.pinButton);
|
||||
|
||||
View clarificationLabel = view.findViewById(R.id.clarification_label);
|
||||
View subHeader = view.findViewById(R.id.verify_subheader);
|
||||
View pinForgotButton = view.findViewById(R.id.forgot_button);
|
||||
|
||||
String code = getModel().getTextCodeEntered();
|
||||
|
||||
timeRemaining = RegistrationLockFragmentArgs.fromBundle(requireArguments()).getTimeRemaining();
|
||||
|
||||
pinForgotButton.setOnClickListener(v -> handleForgottenPin(timeRemaining));
|
||||
|
||||
pinEntry.addTextChangedListener(new TextWatcher() {
|
||||
|
||||
@Override
|
||||
public void beforeTextChanged(CharSequence s, int start, int count, int after) {
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onTextChanged(CharSequence s, int start, int before, int count) {
|
||||
}
|
||||
|
||||
@Override
|
||||
public void afterTextChanged(Editable s) {
|
||||
boolean matchesTextCode = s != null && s.toString().equals(code);
|
||||
clarificationLabel.setVisibility(matchesTextCode ? View.VISIBLE : View.INVISIBLE);
|
||||
subHeader.setVisibility(matchesTextCode ? View.INVISIBLE : View.VISIBLE);
|
||||
}
|
||||
});
|
||||
|
||||
pinEntry.setImeOptions(EditorInfo.IME_ACTION_DONE);
|
||||
pinEntry.setOnEditorActionListener((v, actionId, event) -> {
|
||||
if (actionId == EditorInfo.IME_ACTION_DONE) {
|
||||
hideKeyboard(requireContext(), v);
|
||||
handlePinEntry();
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
});
|
||||
|
||||
pinButton.setOnClickListener((v) -> {
|
||||
hideKeyboard(requireContext(), pinEntry);
|
||||
handlePinEntry();
|
||||
});
|
||||
|
||||
RegistrationViewModel model = getModel();
|
||||
model.getTokenResponseCredentialsPair()
|
||||
.observe(this, pair -> {
|
||||
TokenResponse token = pair.first();
|
||||
String credentials = pair.second();
|
||||
updateContinueText(token, credentials);
|
||||
});
|
||||
|
||||
model.onRegistrationLockFragmentCreate();
|
||||
}
|
||||
|
||||
private void updateContinueText(@Nullable TokenResponse tokenResponse, @Nullable String storageCredentials) {
|
||||
if (tokenResponse == null) {
|
||||
if (storageCredentials == null) {
|
||||
pinButton.setIdleText(getString(R.string.RegistrationActivity_continue));
|
||||
} else {
|
||||
// TODO: This is the case where we can determine they are locked out
|
||||
// no token, but do have storage credentials. Might want to change text.
|
||||
pinButton.setIdleText(getString(R.string.RegistrationActivity_continue));
|
||||
}
|
||||
} else {
|
||||
int triesRemaining = tokenResponse.getTries();
|
||||
if (triesRemaining == 1) {
|
||||
pinButton.setIdleText(getString(R.string.RegistrationActivity_continue_last_attempt));
|
||||
} else {
|
||||
pinButton.setIdleText(getString(R.string.RegistrationActivity_continue_d_attempts_left, triesRemaining));
|
||||
}
|
||||
}
|
||||
pinButton.setText(pinButton.getIdleText());
|
||||
}
|
||||
|
||||
private void handlePinEntry() {
|
||||
final String pin = pinEntry.getText().toString();
|
||||
|
||||
if (TextUtils.isEmpty(pin) || TextUtils.isEmpty(pin.replace(" ", ""))) {
|
||||
Toast.makeText(requireContext(), R.string.RegistrationActivity_you_must_enter_your_registration_lock_PIN, Toast.LENGTH_LONG).show();
|
||||
return;
|
||||
}
|
||||
|
||||
RegistrationViewModel model = getModel();
|
||||
RegistrationService registrationService = RegistrationService.getInstance(model.getNumber().getE164Number(), model.getRegistrationSecret());
|
||||
String storageCredentials = model.getBasicStorageCredentials();
|
||||
TokenResponse tokenResponse = model.getKeyBackupCurrentToken();
|
||||
|
||||
setSpinning(pinButton);
|
||||
|
||||
registrationService.verifyAccount(requireActivity(),
|
||||
model.getFcmToken(),
|
||||
model.getTextCodeEntered(),
|
||||
pin, storageCredentials, tokenResponse,
|
||||
|
||||
new CodeVerificationRequest.VerifyCallback() {
|
||||
|
||||
@Override
|
||||
public void onSuccessfulRegistration() {
|
||||
cancelSpinning(pinButton);
|
||||
|
||||
Navigation.findNavController(requireView()).navigate(RegistrationLockFragmentDirections.actionSuccessfulRegistration());
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onIncorrectRegistrationLockPin(long timeRemaining, String storageCredentials) {
|
||||
model.setStorageCredentials(storageCredentials);
|
||||
cancelSpinning(pinButton);
|
||||
|
||||
pinEntry.setText("");
|
||||
Toast.makeText(requireContext(), R.string.RegistrationActivity_incorrect_registration_lock_pin, Toast.LENGTH_LONG).show();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onIncorrectKbsRegistrationLockPin(@NonNull TokenResponse tokenResponse) {
|
||||
cancelSpinning(pinButton);
|
||||
|
||||
model.setKeyBackupCurrentToken(tokenResponse);
|
||||
|
||||
int triesRemaining = tokenResponse.getTries();
|
||||
|
||||
if (triesRemaining == 0) {
|
||||
handleForgottenPin(timeRemaining);
|
||||
return;
|
||||
}
|
||||
|
||||
new AlertDialog.Builder(requireContext())
|
||||
.setTitle(R.string.RegistrationActivity_pin_incorrect)
|
||||
.setMessage(getString(R.string.RegistrationActivity_you_have_d_tries_remaining, triesRemaining))
|
||||
.setPositiveButton(android.R.string.ok, null)
|
||||
.show();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onTooManyAttempts() {
|
||||
cancelSpinning(pinButton);
|
||||
|
||||
new AlertDialog.Builder(requireContext())
|
||||
.setTitle(R.string.RegistrationActivity_too_many_attempts)
|
||||
.setMessage(R.string.RegistrationActivity_you_have_made_too_many_incorrect_registration_lock_pin_attempts_please_try_again_in_a_day)
|
||||
.setPositiveButton(android.R.string.ok, null)
|
||||
.show();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onError() {
|
||||
cancelSpinning(pinButton);
|
||||
|
||||
Toast.makeText(requireContext(), R.string.RegistrationActivity_error_connecting_to_service, Toast.LENGTH_LONG).show();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private void handleForgottenPin(long timeRemainingMs) {
|
||||
new AlertDialog.Builder(requireContext())
|
||||
.setTitle(R.string.RegistrationActivity_oh_no)
|
||||
.setMessage(getString(R.string.RegistrationActivity_registration_of_this_phone_number_will_be_possible_without_your_registration_lock_pin_after_seven_days_have_passed, (TimeUnit.MILLISECONDS.toDays(timeRemainingMs) + 1)))
|
||||
.setPositiveButton(android.R.string.ok, null)
|
||||
.show();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,340 @@
|
||||
package org.thoughtcrime.securesms.registration.fragments;
|
||||
|
||||
import android.Manifest;
|
||||
import android.annotation.SuppressLint;
|
||||
import android.content.Context;
|
||||
import android.graphics.Canvas;
|
||||
import android.graphics.Paint;
|
||||
import android.os.AsyncTask;
|
||||
import android.os.Bundle;
|
||||
import android.text.Editable;
|
||||
import android.text.Spanned;
|
||||
import android.text.TextWatcher;
|
||||
import android.text.style.ReplacementSpan;
|
||||
import android.view.LayoutInflater;
|
||||
import android.view.View;
|
||||
import android.view.ViewGroup;
|
||||
import android.view.inputmethod.InputMethodManager;
|
||||
import android.widget.EditText;
|
||||
import android.widget.TextView;
|
||||
import android.widget.Toast;
|
||||
|
||||
import androidx.annotation.MainThread;
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.annotation.Nullable;
|
||||
import androidx.appcompat.app.AlertDialog;
|
||||
import androidx.navigation.Navigation;
|
||||
|
||||
import com.dd.CircularProgressButton;
|
||||
|
||||
import net.sqlcipher.database.SQLiteDatabase;
|
||||
|
||||
import org.greenrobot.eventbus.EventBus;
|
||||
import org.greenrobot.eventbus.Subscribe;
|
||||
import org.greenrobot.eventbus.ThreadMode;
|
||||
import org.thoughtcrime.securesms.R;
|
||||
import org.thoughtcrime.securesms.backup.BackupPassphrase;
|
||||
import org.thoughtcrime.securesms.backup.FullBackupBase;
|
||||
import org.thoughtcrime.securesms.backup.FullBackupImporter;
|
||||
import org.thoughtcrime.securesms.crypto.AttachmentSecretProvider;
|
||||
import org.thoughtcrime.securesms.database.DatabaseFactory;
|
||||
import org.thoughtcrime.securesms.database.NoExternalStorageException;
|
||||
import org.thoughtcrime.securesms.logging.Log;
|
||||
import org.thoughtcrime.securesms.notifications.NotificationChannels;
|
||||
import org.thoughtcrime.securesms.permissions.Permissions;
|
||||
import org.thoughtcrime.securesms.service.LocalBackupListener;
|
||||
import org.thoughtcrime.securesms.util.BackupUtil;
|
||||
import org.thoughtcrime.securesms.util.DateUtils;
|
||||
import org.thoughtcrime.securesms.util.TextSecurePreferences;
|
||||
import org.thoughtcrime.securesms.util.Util;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.util.Locale;
|
||||
|
||||
public final class RestoreBackupFragment extends BaseRegistrationFragment {
|
||||
|
||||
private static final String TAG = Log.tag(RestoreBackupFragment.class);
|
||||
|
||||
private TextView restoreBackupSize;
|
||||
private TextView restoreBackupTime;
|
||||
private TextView restoreBackupProgress;
|
||||
private CircularProgressButton restoreButton;
|
||||
private View skipRestoreButton;
|
||||
|
||||
@Override
|
||||
public View onCreateView(LayoutInflater inflater, ViewGroup container,
|
||||
Bundle savedInstanceState) {
|
||||
return inflater.inflate(R.layout.fragment_registration_restore_backup, container, false);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onViewCreated(@NonNull View view, @Nullable Bundle savedInstanceState) {
|
||||
super.onViewCreated(view, savedInstanceState);
|
||||
|
||||
setDebugLogSubmitMultiTapView(view.findViewById(R.id.verify_header));
|
||||
|
||||
Log.i(TAG, "Backup restore.");
|
||||
|
||||
restoreBackupSize = view.findViewById(R.id.backup_size_text);
|
||||
restoreBackupTime = view.findViewById(R.id.backup_created_text);
|
||||
restoreBackupProgress = view.findViewById(R.id.backup_progress_text);
|
||||
restoreButton = view.findViewById(R.id.restore_button);
|
||||
skipRestoreButton = view.findViewById(R.id.skip_restore_button);
|
||||
|
||||
skipRestoreButton.setOnClickListener((v) -> {
|
||||
Log.i(TAG, "User skipped backup restore.");
|
||||
Navigation.findNavController(view)
|
||||
.navigate(RestoreBackupFragmentDirections.actionSkip());
|
||||
});
|
||||
|
||||
if (isReregister()) {
|
||||
Log.i(TAG, "Skipping backup restore during re-register.");
|
||||
Navigation.findNavController(view)
|
||||
.navigate(RestoreBackupFragmentDirections.actionSkipNoReturn());
|
||||
return;
|
||||
}
|
||||
|
||||
if (TextSecurePreferences.isBackupEnabled(requireContext())) {
|
||||
Log.i(TAG, "Backups enabled, so a backup must have been previously restored.");
|
||||
Navigation.findNavController(view)
|
||||
.navigate(RestoreBackupFragmentDirections.actionSkipNoReturn());
|
||||
return;
|
||||
}
|
||||
|
||||
if (!Permissions.hasAll(requireContext(), Manifest.permission.WRITE_EXTERNAL_STORAGE)) {
|
||||
Log.i(TAG, "Skipping backup detection. We don't have the permission.");
|
||||
Navigation.findNavController(view)
|
||||
.navigate(RestoreBackupFragmentDirections.actionSkipNoReturn());
|
||||
} else {
|
||||
initializeBackupDetection(view);
|
||||
}
|
||||
}
|
||||
|
||||
@SuppressLint("StaticFieldLeak")
|
||||
private void initializeBackupDetection(@NonNull View view) {
|
||||
searchForBackup(backup -> {
|
||||
Context context = getContext();
|
||||
if (context == null) {
|
||||
Log.i(TAG, "No context on fragment, must have navigated away.");
|
||||
return;
|
||||
}
|
||||
|
||||
if (backup == null) {
|
||||
Log.i(TAG, "Skipping backup detection. No backup found, or permission revoked since.");
|
||||
Navigation.findNavController(view)
|
||||
.navigate(RestoreBackupFragmentDirections.actionNoBackupFound());
|
||||
} else {
|
||||
restoreBackupSize.setText(getString(R.string.RegistrationActivity_backup_size_s, Util.getPrettyFileSize(backup.getSize())));
|
||||
restoreBackupTime.setText(getString(R.string.RegistrationActivity_backup_timestamp_s, DateUtils.getExtendedRelativeTimeSpanString(requireContext(), Locale.US, backup.getTimestamp())));
|
||||
|
||||
restoreButton.setOnClickListener((v) -> handleRestore(v.getContext(), backup));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
interface OnBackupSearchResultListener {
|
||||
|
||||
@MainThread
|
||||
void run(@Nullable BackupUtil.BackupInfo backup);
|
||||
}
|
||||
|
||||
static void searchForBackup(@NonNull OnBackupSearchResultListener listener) {
|
||||
new AsyncTask<Void, Void, BackupUtil.BackupInfo>() {
|
||||
@Override
|
||||
protected @Nullable
|
||||
BackupUtil.BackupInfo doInBackground(Void... voids) {
|
||||
try {
|
||||
return BackupUtil.getLatestBackup();
|
||||
} catch (NoExternalStorageException e) {
|
||||
Log.w(TAG, e);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void onPostExecute(@Nullable BackupUtil.BackupInfo backup) {
|
||||
listener.run(backup);
|
||||
}
|
||||
}.execute();
|
||||
}
|
||||
|
||||
private void handleRestore(@NonNull Context context, @NonNull BackupUtil.BackupInfo backup) {
|
||||
View view = LayoutInflater.from(context).inflate(R.layout.enter_backup_passphrase_dialog, null);
|
||||
EditText prompt = view.findViewById(R.id.restore_passphrase_input);
|
||||
|
||||
prompt.addTextChangedListener(new PassphraseAsYouTypeFormatter());
|
||||
|
||||
new AlertDialog.Builder(context)
|
||||
.setTitle(R.string.RegistrationActivity_enter_backup_passphrase)
|
||||
.setView(view)
|
||||
.setPositiveButton(R.string.RegistrationActivity_restore, (dialog, which) -> {
|
||||
InputMethodManager inputMethodManager = (InputMethodManager) context.getSystemService(Context.INPUT_METHOD_SERVICE);
|
||||
inputMethodManager.hideSoftInputFromWindow(prompt.getWindowToken(), 0);
|
||||
|
||||
setSpinning(restoreButton);
|
||||
skipRestoreButton.setVisibility(View.INVISIBLE);
|
||||
|
||||
String passphrase = prompt.getText().toString();
|
||||
|
||||
restoreAsynchronously(context, backup, passphrase);
|
||||
})
|
||||
.setNegativeButton(android.R.string.cancel, null)
|
||||
.show();
|
||||
|
||||
Log.i(TAG, "Prompt for backup passphrase shown to user.");
|
||||
}
|
||||
|
||||
@SuppressLint("StaticFieldLeak")
|
||||
private void restoreAsynchronously(@NonNull Context context,
|
||||
@NonNull BackupUtil.BackupInfo backup,
|
||||
@NonNull String passphrase)
|
||||
{
|
||||
new AsyncTask<Void, Void, BackupImportResult>() {
|
||||
@Override
|
||||
protected BackupImportResult doInBackground(Void... voids) {
|
||||
try {
|
||||
Log.i(TAG, "Starting backup restore.");
|
||||
|
||||
SQLiteDatabase database = DatabaseFactory.getBackupDatabase(context);
|
||||
|
||||
FullBackupImporter.importFile(context,
|
||||
AttachmentSecretProvider.getInstance(context).getOrCreateAttachmentSecret(),
|
||||
database,
|
||||
backup.getFile(),
|
||||
passphrase);
|
||||
|
||||
DatabaseFactory.upgradeRestored(context, database);
|
||||
NotificationChannels.restoreContactNotificationChannels(context);
|
||||
|
||||
LocalBackupListener.setNextBackupTimeToIntervalFromNow(context);
|
||||
BackupPassphrase.set(context, passphrase);
|
||||
TextSecurePreferences.setBackupEnabled(context, true);
|
||||
LocalBackupListener.schedule(context);
|
||||
|
||||
Log.i(TAG, "Backup restore complete.");
|
||||
return BackupImportResult.SUCCESS;
|
||||
} catch (FullBackupImporter.DatabaseDowngradeException e) {
|
||||
Log.w(TAG, "Failed due to the backup being from a newer version of Signal.", e);
|
||||
return BackupImportResult.FAILURE_VERSION_DOWNGRADE;
|
||||
} catch (IOException e) {
|
||||
Log.w(TAG, e);
|
||||
return BackupImportResult.FAILURE_UNKNOWN;
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void onPostExecute(@NonNull BackupImportResult result) {
|
||||
cancelSpinning(restoreButton);
|
||||
skipRestoreButton.setVisibility(View.VISIBLE);
|
||||
|
||||
restoreBackupProgress.setText("");
|
||||
|
||||
switch (result) {
|
||||
case SUCCESS:
|
||||
Log.i(TAG, "Successful backup restore.");
|
||||
break;
|
||||
case FAILURE_VERSION_DOWNGRADE:
|
||||
Toast.makeText(context, R.string.RegistrationActivity_backup_failure_downgrade, Toast.LENGTH_LONG).show();
|
||||
break;
|
||||
case FAILURE_UNKNOWN:
|
||||
Toast.makeText(context, R.string.RegistrationActivity_incorrect_backup_passphrase, Toast.LENGTH_LONG).show();
|
||||
break;
|
||||
}
|
||||
}
|
||||
}.execute();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onStart() {
|
||||
super.onStart();
|
||||
EventBus.getDefault().register(this);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onStop() {
|
||||
super.onStop();
|
||||
EventBus.getDefault().unregister(this);
|
||||
}
|
||||
|
||||
@Subscribe(threadMode = ThreadMode.MAIN)
|
||||
public void onEvent(@NonNull FullBackupBase.BackupEvent event) {
|
||||
int count = event.getCount();
|
||||
|
||||
if (count == 0) {
|
||||
restoreBackupProgress.setText(R.string.RegistrationActivity_checking);
|
||||
} else {
|
||||
restoreBackupProgress.setText(getString(R.string.RegistrationActivity_d_messages_so_far, count));
|
||||
}
|
||||
|
||||
setSpinning(restoreButton);
|
||||
skipRestoreButton.setVisibility(View.INVISIBLE);
|
||||
|
||||
if (event.getType() == FullBackupBase.BackupEvent.Type.FINISHED) {
|
||||
Navigation.findNavController(requireView())
|
||||
.navigate(RestoreBackupFragmentDirections.actionBackupRestored());
|
||||
}
|
||||
}
|
||||
|
||||
private enum BackupImportResult {
|
||||
SUCCESS,
|
||||
FAILURE_VERSION_DOWNGRADE,
|
||||
FAILURE_UNKNOWN
|
||||
}
|
||||
|
||||
private static class PassphraseAsYouTypeFormatter implements TextWatcher {
|
||||
|
||||
private static final int GROUP_SIZE = 5;
|
||||
|
||||
@Override
|
||||
public void afterTextChanged(Editable editable) {
|
||||
removeSpans(editable);
|
||||
|
||||
addSpans(editable);
|
||||
}
|
||||
|
||||
private void removeSpans(Editable editable) {
|
||||
SpaceSpan[] paddingSpans = editable.getSpans(0, editable.length(), SpaceSpan.class);
|
||||
|
||||
for (SpaceSpan span : paddingSpans) {
|
||||
editable.removeSpan(span);
|
||||
}
|
||||
}
|
||||
|
||||
private void addSpans(Editable editable) {
|
||||
final int length = editable.length();
|
||||
|
||||
for (int i = GROUP_SIZE; i < length; i += GROUP_SIZE) {
|
||||
editable.setSpan(new SpaceSpan(), i - 1, i, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE);
|
||||
}
|
||||
|
||||
if (editable.length() > 30) {
|
||||
editable.delete(30, editable.length());
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void beforeTextChanged(CharSequence s, int start, int count, int after) {
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onTextChanged(CharSequence s, int start, int before, int count) {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A {@link ReplacementSpan} adds a small space after a single character.
|
||||
* Based on https://stackoverflow.com/a/51949578
|
||||
*/
|
||||
private static class SpaceSpan extends ReplacementSpan {
|
||||
|
||||
@Override
|
||||
public int getSize(@NonNull Paint paint, CharSequence text, int start, int end, Paint.FontMetricsInt fm) {
|
||||
return (int) (paint.measureText(text, start, end) * 1.7f);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void draw(@NonNull Canvas canvas, CharSequence text, int start, int end, float x, int top, int y, int bottom, @NonNull Paint paint) {
|
||||
canvas.drawText(text.subSequence(start, end).toString(), x, y, paint);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,152 @@
|
||||
package org.thoughtcrime.securesms.registration.fragments;
|
||||
|
||||
import android.Manifest;
|
||||
import android.annotation.SuppressLint;
|
||||
import android.content.Context;
|
||||
import android.os.Bundle;
|
||||
import android.text.TextUtils;
|
||||
import android.view.LayoutInflater;
|
||||
import android.view.View;
|
||||
import android.view.ViewGroup;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.annotation.Nullable;
|
||||
import androidx.fragment.app.FragmentActivity;
|
||||
import androidx.navigation.ActivityNavigator;
|
||||
import androidx.navigation.Navigation;
|
||||
|
||||
import com.dd.CircularProgressButton;
|
||||
import com.google.i18n.phonenumbers.PhoneNumberUtil;
|
||||
import com.google.i18n.phonenumbers.Phonenumber;
|
||||
|
||||
import org.thoughtcrime.securesms.R;
|
||||
import org.thoughtcrime.securesms.logging.Log;
|
||||
import org.thoughtcrime.securesms.permissions.Permissions;
|
||||
import org.thoughtcrime.securesms.registration.viewmodel.RegistrationViewModel;
|
||||
import org.thoughtcrime.securesms.util.CommunicationActions;
|
||||
import org.thoughtcrime.securesms.util.TextSecurePreferences;
|
||||
import org.thoughtcrime.securesms.util.Util;
|
||||
import org.whispersystems.libsignal.util.guava.Optional;
|
||||
|
||||
public final class WelcomeFragment extends BaseRegistrationFragment {
|
||||
|
||||
private static final String TAG = Log.tag(WelcomeFragment.class);
|
||||
|
||||
private CircularProgressButton continueButton;
|
||||
|
||||
@Override
|
||||
public View onCreateView(LayoutInflater inflater, ViewGroup container,
|
||||
Bundle savedInstanceState) {
|
||||
return inflater.inflate(isReregister() ? R.layout.fragment_registration_blank
|
||||
: R.layout.fragment_registration_welcome,
|
||||
container,
|
||||
false);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onViewCreated(@NonNull View view, @Nullable Bundle savedInstanceState) {
|
||||
super.onViewCreated(view, savedInstanceState);
|
||||
|
||||
if (isReregister()) {
|
||||
RegistrationViewModel model = getModel();
|
||||
|
||||
if (model.hasRestoreFlowBeenShown()) {
|
||||
Log.i(TAG, "We've come back to the home fragment on a restore, user must be backing out");
|
||||
if (!Navigation.findNavController(view).popBackStack()) {
|
||||
FragmentActivity activity = requireActivity();
|
||||
activity.finish();
|
||||
ActivityNavigator.applyPopAnimationsToPendingTransition(activity);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
initializeNumber();
|
||||
|
||||
Log.i(TAG, "Skipping restore because this is a reregistration.");
|
||||
model.setWelcomeSkippedOnRestore();
|
||||
Navigation.findNavController(view)
|
||||
.navigate(WelcomeFragmentDirections.actionSkipRestore());
|
||||
|
||||
} else {
|
||||
|
||||
setDebugLogSubmitMultiTapView(view.findViewById(R.id.image));
|
||||
setDebugLogSubmitMultiTapView(view.findViewById(R.id.title));
|
||||
|
||||
continueButton = view.findViewById(R.id.welcome_continue_button);
|
||||
continueButton.setOnClickListener(this::continueClicked);
|
||||
|
||||
view.findViewById(R.id.welcome_terms_button).setOnClickListener(v -> onTermsClicked());
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onRequestPermissionsResult(int requestCode, @NonNull String[] permissions, @NonNull int[] grantResults) {
|
||||
Permissions.onRequestPermissionsResult(this, requestCode, permissions, grantResults);
|
||||
}
|
||||
|
||||
private void continueClicked(@NonNull View view) {
|
||||
Permissions.with(this)
|
||||
.request(Manifest.permission.WRITE_CONTACTS,
|
||||
Manifest.permission.READ_CONTACTS,
|
||||
Manifest.permission.WRITE_EXTERNAL_STORAGE,
|
||||
Manifest.permission.READ_EXTERNAL_STORAGE,
|
||||
Manifest.permission.READ_PHONE_STATE)
|
||||
.ifNecessary()
|
||||
.withRationaleDialog(getString(R.string.RegistrationActivity_signal_needs_access_to_your_contacts_and_media_in_order_to_connect_with_friends),
|
||||
R.drawable.ic_contacts_white_48dp, R.drawable.ic_folder_white_48dp)
|
||||
.onAnyResult(() -> {
|
||||
gatherInformationAndContinue(continueButton);
|
||||
})
|
||||
.execute();
|
||||
}
|
||||
|
||||
private void gatherInformationAndContinue(@NonNull View view) {
|
||||
setSpinning(continueButton);
|
||||
|
||||
RestoreBackupFragment.searchForBackup(backup -> {
|
||||
Context context = getContext();
|
||||
if (context == null) {
|
||||
Log.i(TAG, "No context on fragment, must have navigated away.");
|
||||
return;
|
||||
}
|
||||
|
||||
TextSecurePreferences.setHasSeenWelcomeScreen(requireContext(), true);
|
||||
|
||||
initializeNumber();
|
||||
|
||||
cancelSpinning(continueButton);
|
||||
|
||||
if (backup == null) {
|
||||
Log.i(TAG, "Skipping backup. No backup found, or no permission to look.");
|
||||
Navigation.findNavController(view)
|
||||
.navigate(WelcomeFragmentDirections.actionSkipRestore());
|
||||
} else {
|
||||
Navigation.findNavController(view)
|
||||
.navigate(WelcomeFragmentDirections.actionRestore());
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@SuppressLint("MissingPermission")
|
||||
private void initializeNumber() {
|
||||
Optional<Phonenumber.PhoneNumber> localNumber = Optional.absent();
|
||||
|
||||
if (Permissions.hasAll(requireContext(), Manifest.permission.READ_PHONE_STATE)) {
|
||||
localNumber = Util.getDeviceNumber(requireContext());
|
||||
}
|
||||
|
||||
if (localNumber.isPresent()) {
|
||||
getModel().onNumberDetected(localNumber.get().getCountryCode(), localNumber.get().getNationalNumber());
|
||||
} else {
|
||||
Optional<String> simCountryIso = Util.getSimCountryIso(requireContext());
|
||||
|
||||
if (simCountryIso.isPresent() && !TextUtils.isEmpty(simCountryIso.get())) {
|
||||
getModel().onNumberDetected(PhoneNumberUtil.getInstance().getCountryCodeForRegion(simCountryIso.get()), 0);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void onTermsClicked() {
|
||||
CommunicationActions.openBrowserLink(requireContext(), RegistrationConstants.TERMS_AND_CONDITIONS_URL);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,300 @@
|
||||
package org.thoughtcrime.securesms.registration.service;
|
||||
|
||||
import android.content.Context;
|
||||
import android.os.AsyncTask;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.annotation.Nullable;
|
||||
|
||||
import org.thoughtcrime.securesms.crypto.IdentityKeyUtil;
|
||||
import org.thoughtcrime.securesms.crypto.PreKeyUtil;
|
||||
import org.thoughtcrime.securesms.crypto.SessionUtil;
|
||||
import org.thoughtcrime.securesms.crypto.UnidentifiedAccessUtil;
|
||||
import org.thoughtcrime.securesms.database.DatabaseFactory;
|
||||
import org.thoughtcrime.securesms.database.IdentityDatabase;
|
||||
import org.thoughtcrime.securesms.database.RecipientDatabase;
|
||||
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies;
|
||||
import org.thoughtcrime.securesms.jobmanager.JobManager;
|
||||
import org.thoughtcrime.securesms.jobs.DirectoryRefreshJob;
|
||||
import org.thoughtcrime.securesms.jobs.RotateCertificateJob;
|
||||
import org.thoughtcrime.securesms.lock.RegistrationLockReminders;
|
||||
import org.thoughtcrime.securesms.logging.Log;
|
||||
import org.thoughtcrime.securesms.migrations.RegistrationPinV2MigrationJob;
|
||||
import org.thoughtcrime.securesms.push.AccountManagerFactory;
|
||||
import org.thoughtcrime.securesms.recipients.Recipient;
|
||||
import org.thoughtcrime.securesms.recipients.RecipientId;
|
||||
import org.thoughtcrime.securesms.service.DirectoryRefreshListener;
|
||||
import org.thoughtcrime.securesms.service.RotateSignedPreKeyListener;
|
||||
import org.thoughtcrime.securesms.util.FeatureFlags;
|
||||
import org.thoughtcrime.securesms.util.TextSecurePreferences;
|
||||
import org.whispersystems.libsignal.IdentityKeyPair;
|
||||
import org.whispersystems.libsignal.state.PreKeyRecord;
|
||||
import org.whispersystems.libsignal.state.SignedPreKeyRecord;
|
||||
import org.whispersystems.libsignal.util.KeyHelper;
|
||||
import org.whispersystems.libsignal.util.guava.Optional;
|
||||
import org.whispersystems.signalservice.api.KeyBackupService;
|
||||
import org.whispersystems.signalservice.api.KeyBackupServicePinException;
|
||||
import org.whispersystems.signalservice.api.RegistrationLockData;
|
||||
import org.whispersystems.signalservice.api.SignalServiceAccountManager;
|
||||
import org.whispersystems.signalservice.api.push.exceptions.RateLimitException;
|
||||
import org.whispersystems.signalservice.internal.contacts.crypto.UnauthenticatedResponseException;
|
||||
import org.whispersystems.signalservice.internal.contacts.entities.TokenResponse;
|
||||
import org.whispersystems.signalservice.internal.push.LockedException;
|
||||
import org.whispersystems.signalservice.internal.registrationpin.InvalidPinException;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.util.List;
|
||||
import java.util.UUID;
|
||||
|
||||
public final class CodeVerificationRequest {
|
||||
|
||||
private static final String TAG = Log.tag(CodeVerificationRequest.class);
|
||||
|
||||
static TokenResponse getToken(@Nullable String basicStorageCredentials) throws IOException {
|
||||
if (basicStorageCredentials == null) return null;
|
||||
if (!FeatureFlags.KBS) return null;
|
||||
return ApplicationDependencies.getKeyBackupService().getToken(basicStorageCredentials);
|
||||
}
|
||||
|
||||
private enum Result {
|
||||
SUCCESS,
|
||||
PIN_LOCKED,
|
||||
KBS_WRONG_PIN,
|
||||
RATE_LIMITED,
|
||||
ERROR
|
||||
}
|
||||
|
||||
/**
|
||||
* Asynchronously verify the account via the code.
|
||||
*
|
||||
* @param fcmToken The FCM token for the device.
|
||||
* @param code The code that was delivered to the user.
|
||||
* @param pin The users registration pin.
|
||||
* @param callback Exactly one method on this callback will be called.
|
||||
* @param kbsTokenResponse By keeping the token, on failure, a newly returned token will be reused in subsequent pin
|
||||
* attempts, preventing certain attacks, we can also track the attempts making missing replies easier to spot.
|
||||
*/
|
||||
static void verifyAccount(@NonNull Context context,
|
||||
@NonNull Credentials credentials,
|
||||
@Nullable String fcmToken,
|
||||
@NonNull String code,
|
||||
@Nullable String pin,
|
||||
@Nullable String basicStorageCredentials,
|
||||
@Nullable TokenResponse kbsTokenResponse,
|
||||
@NonNull VerifyCallback callback)
|
||||
{
|
||||
new AsyncTask<Void, Void, Result>() {
|
||||
|
||||
private volatile LockedException lockedException;
|
||||
private volatile KeyBackupSystemWrongPinException keyBackupSystemWrongPinException;
|
||||
|
||||
@Override
|
||||
protected Result doInBackground(Void... voids) {
|
||||
try {
|
||||
verifyAccount(context, credentials, code, pin, basicStorageCredentials, kbsTokenResponse, fcmToken);
|
||||
return Result.SUCCESS;
|
||||
} catch (LockedException e) {
|
||||
Log.w(TAG, e);
|
||||
lockedException = e;
|
||||
return Result.PIN_LOCKED;
|
||||
} catch (RateLimitException e) {
|
||||
Log.w(TAG, e);
|
||||
return Result.RATE_LIMITED;
|
||||
} catch (IOException e) {
|
||||
Log.w(TAG, e);
|
||||
return Result.ERROR;
|
||||
} catch (KeyBackupSystemWrongPinException e) {
|
||||
keyBackupSystemWrongPinException = e;
|
||||
return Result.KBS_WRONG_PIN;
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void onPostExecute(Result result) {
|
||||
switch (result) {
|
||||
case SUCCESS:
|
||||
handleSuccessfulRegistration(context);
|
||||
callback.onSuccessfulRegistration();
|
||||
break;
|
||||
case PIN_LOCKED:
|
||||
callback.onIncorrectRegistrationLockPin(lockedException.getTimeRemaining(), lockedException.getBasicStorageCredentials());
|
||||
break;
|
||||
case RATE_LIMITED:
|
||||
callback.onTooManyAttempts();
|
||||
break;
|
||||
case ERROR:
|
||||
callback.onError();
|
||||
break;
|
||||
case KBS_WRONG_PIN:
|
||||
callback.onIncorrectKbsRegistrationLockPin(keyBackupSystemWrongPinException.getTokenResponse());
|
||||
break;
|
||||
}
|
||||
}
|
||||
}.execute();
|
||||
}
|
||||
|
||||
private static void handleSuccessfulRegistration(@NonNull Context context) {
|
||||
JobManager jobManager = ApplicationDependencies.getJobManager();
|
||||
jobManager.add(new DirectoryRefreshJob(false));
|
||||
jobManager.add(new RotateCertificateJob(context));
|
||||
|
||||
DirectoryRefreshListener.schedule(context);
|
||||
RotateSignedPreKeyListener.schedule(context);
|
||||
}
|
||||
|
||||
private static void verifyAccount(@NonNull Context context,
|
||||
@NonNull Credentials credentials,
|
||||
@NonNull String code,
|
||||
@Nullable String pin,
|
||||
@Nullable String basicStorageCredentials,
|
||||
@Nullable TokenResponse kbsTokenResponse,
|
||||
@Nullable String fcmToken)
|
||||
throws IOException, KeyBackupSystemWrongPinException
|
||||
{
|
||||
int registrationId = KeyHelper.generateRegistrationId(false);
|
||||
byte[] unidentifiedAccessKey = UnidentifiedAccessUtil.getSelfUnidentifiedAccessKey(context);
|
||||
boolean universalUnidentifiedAccess = TextSecurePreferences.isUniversalUnidentifiedAccess(context);
|
||||
|
||||
TextSecurePreferences.setLocalRegistrationId(context, registrationId);
|
||||
SessionUtil.archiveAllSessions(context);
|
||||
|
||||
SignalServiceAccountManager accountManager = AccountManagerFactory.createUnauthenticated(context, credentials.getE164number(), credentials.getPassword());
|
||||
RegistrationLockData kbsData = restoreMasterKey(pin, basicStorageCredentials, kbsTokenResponse);
|
||||
String registrationLock = kbsData != null ? kbsData.getMasterKey().getRegistrationLock() : null;
|
||||
boolean present = fcmToken != null;
|
||||
|
||||
UUID uuid = accountManager.verifyAccountWithCode(code, null, registrationId, !present,
|
||||
pin, registrationLock,
|
||||
unidentifiedAccessKey, universalUnidentifiedAccess);
|
||||
|
||||
IdentityKeyPair identityKey = IdentityKeyUtil.getIdentityKeyPair(context);
|
||||
List<PreKeyRecord> records = PreKeyUtil.generatePreKeys(context);
|
||||
SignedPreKeyRecord signedPreKey = PreKeyUtil.generateSignedPreKey(context, identityKey, true);
|
||||
|
||||
accountManager = AccountManagerFactory.createAuthenticated(context, uuid, credentials.getE164number(), credentials.getPassword());
|
||||
accountManager.setPreKeys(identityKey.getPublicKey(), signedPreKey, records);
|
||||
|
||||
if (present) {
|
||||
accountManager.setGcmId(Optional.fromNullable(fcmToken));
|
||||
}
|
||||
|
||||
RecipientDatabase recipientDatabase = DatabaseFactory.getRecipientDatabase(context);
|
||||
RecipientId selfId = recipientDatabase.getOrInsertFromE164(credentials.getE164number());
|
||||
|
||||
recipientDatabase.setProfileSharing(selfId, true);
|
||||
recipientDatabase.markRegistered(selfId, uuid);
|
||||
|
||||
TextSecurePreferences.setLocalNumber(context, credentials.getE164number());
|
||||
TextSecurePreferences.setLocalUuid(context, uuid);
|
||||
TextSecurePreferences.setFcmToken(context, fcmToken);
|
||||
TextSecurePreferences.setFcmDisabled(context, !present);
|
||||
TextSecurePreferences.setWebsocketRegistered(context, true);
|
||||
|
||||
DatabaseFactory.getIdentityDatabase(context)
|
||||
.saveIdentity(Recipient.self().getId(),
|
||||
identityKey.getPublicKey(), IdentityDatabase.VerifiedStatus.VERIFIED,
|
||||
true, System.currentTimeMillis(), true);
|
||||
|
||||
TextSecurePreferences.setVerifying(context, false);
|
||||
TextSecurePreferences.setPushRegistered(context, true);
|
||||
TextSecurePreferences.setPushServerPassword(context, credentials.getPassword());
|
||||
TextSecurePreferences.setSignedPreKeyRegistered(context, true);
|
||||
TextSecurePreferences.setPromptedPushRegistration(context, true);
|
||||
TextSecurePreferences.setUnauthorizedReceived(context, false);
|
||||
TextSecurePreferences.setRegistrationLockMasterKey(context, kbsData, System.currentTimeMillis());
|
||||
if (kbsData == null) {
|
||||
//noinspection deprecation Only acceptable place to write the old pin.
|
||||
TextSecurePreferences.setDeprecatedRegistrationLockPin(context, pin);
|
||||
if (pin != null) {
|
||||
if (FeatureFlags.KBS) {
|
||||
Log.i(TAG, "Pin V1 successfully entered during registration, scheduling a migration to Pin V2");
|
||||
ApplicationDependencies.getJobManager().add(new RegistrationPinV2MigrationJob());
|
||||
}
|
||||
}
|
||||
} else {
|
||||
repostPinToResetTries(context, pin, kbsData);
|
||||
}
|
||||
TextSecurePreferences.setRegistrationLockEnabled(context, pin != null);
|
||||
if (pin != null) {
|
||||
TextSecurePreferences.setRegistrationLockLastReminderTime(context, System.currentTimeMillis());
|
||||
TextSecurePreferences.setRegistrationLockNextReminderInterval(context, RegistrationLockReminders.INITIAL_INTERVAL);
|
||||
}
|
||||
}
|
||||
|
||||
private static void repostPinToResetTries(@NonNull Context context, @Nullable String pin, @NonNull RegistrationLockData kbsData) {
|
||||
if (!FeatureFlags.KBS) return;
|
||||
|
||||
KeyBackupService keyBackupService = ApplicationDependencies.getKeyBackupService();
|
||||
|
||||
try {
|
||||
RegistrationLockData newData = keyBackupService.newPinChangeSession(kbsData.getTokenResponse())
|
||||
.setPin(pin);
|
||||
TextSecurePreferences.setRegistrationLockMasterKey(context, newData, System.currentTimeMillis());
|
||||
} catch (IOException e) {
|
||||
Log.w(TAG, "May have failed to reset pin attempts!", e);
|
||||
} catch (UnauthenticatedResponseException e) {
|
||||
Log.w(TAG, "Failed to reset pin attempts", e);
|
||||
} catch (InvalidPinException e) {
|
||||
throw new AssertionError(e);
|
||||
}
|
||||
}
|
||||
|
||||
private static @Nullable RegistrationLockData restoreMasterKey(@Nullable String pin,
|
||||
@Nullable String basicStorageCredentials,
|
||||
@Nullable TokenResponse tokenResponse)
|
||||
throws IOException, KeyBackupSystemWrongPinException
|
||||
{
|
||||
if (pin == null) return null;
|
||||
|
||||
if (basicStorageCredentials == null) {
|
||||
Log.i(TAG, "No storage credentials supplied, pin is not on KBS");
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!FeatureFlags.KBS) {
|
||||
Log.w(TAG, "User appears to have a KBS pin, but this build has KBS off.");
|
||||
return null;
|
||||
}
|
||||
|
||||
KeyBackupService keyBackupService = ApplicationDependencies.getKeyBackupService();
|
||||
|
||||
Log.i(TAG, "Opening key backup service session");
|
||||
KeyBackupService.RestoreSession session = keyBackupService.newRegistrationSession(basicStorageCredentials, tokenResponse);
|
||||
|
||||
try {
|
||||
Log.i(TAG, "Restoring pin from KBS");
|
||||
RegistrationLockData kbsData = session.restorePin(pin);
|
||||
if (kbsData != null) {
|
||||
Log.i(TAG, "Found registration lock token on KBS.");
|
||||
} else {
|
||||
Log.i(TAG, "No KBS data found.");
|
||||
}
|
||||
return kbsData;
|
||||
} catch (UnauthenticatedResponseException e) {
|
||||
Log.w(TAG, "Failed to restore key", e);
|
||||
throw new IOException(e);
|
||||
} catch (KeyBackupServicePinException e) {
|
||||
Log.w(TAG, "Incorrect pin", e);
|
||||
throw new KeyBackupSystemWrongPinException(e.getToken());
|
||||
} catch (InvalidPinException e) {
|
||||
Log.w(TAG, "Invalid pin", e);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
public interface VerifyCallback {
|
||||
|
||||
void onSuccessfulRegistration();
|
||||
|
||||
/**
|
||||
* @param timeRemaining Time until pin expires and number can be reused.
|
||||
*/
|
||||
void onIncorrectRegistrationLockPin(long timeRemaining, String storageCredentials);
|
||||
|
||||
void onIncorrectKbsRegistrationLockPin(@NonNull TokenResponse kbsTokenResponse);
|
||||
|
||||
void onTooManyAttempts();
|
||||
|
||||
void onError();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
package org.thoughtcrime.securesms.registration.service;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
|
||||
public final class Credentials {
|
||||
|
||||
private final String e164number;
|
||||
private final String password;
|
||||
|
||||
public Credentials(@NonNull String e164number, @NonNull String password) {
|
||||
this.e164number = e164number;
|
||||
this.password = password;
|
||||
}
|
||||
|
||||
public @NonNull String getE164number() {
|
||||
return e164number;
|
||||
}
|
||||
|
||||
public @NonNull String getPassword() {
|
||||
return password;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
package org.thoughtcrime.securesms.registration.service;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
|
||||
import org.whispersystems.signalservice.internal.contacts.entities.TokenResponse;
|
||||
|
||||
final class KeyBackupSystemWrongPinException extends Exception {
|
||||
|
||||
private final TokenResponse tokenResponse;
|
||||
|
||||
KeyBackupSystemWrongPinException(@NonNull TokenResponse tokenResponse){
|
||||
this.tokenResponse = tokenResponse;
|
||||
}
|
||||
|
||||
@NonNull TokenResponse getTokenResponse() {
|
||||
return tokenResponse;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,154 @@
|
||||
package org.thoughtcrime.securesms.registration.service;
|
||||
|
||||
import android.annotation.SuppressLint;
|
||||
import android.content.Context;
|
||||
import android.os.AsyncTask;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.annotation.Nullable;
|
||||
|
||||
import org.thoughtcrime.securesms.gcm.FcmUtil;
|
||||
import org.thoughtcrime.securesms.logging.Log;
|
||||
import org.thoughtcrime.securesms.push.AccountManagerFactory;
|
||||
import org.thoughtcrime.securesms.registration.PushChallengeRequest;
|
||||
import org.thoughtcrime.securesms.util.TextSecurePreferences;
|
||||
import org.whispersystems.libsignal.util.guava.Optional;
|
||||
import org.whispersystems.signalservice.api.SignalServiceAccountManager;
|
||||
import org.whispersystems.signalservice.api.push.exceptions.CaptchaRequiredException;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.util.Locale;
|
||||
|
||||
public final class RegistrationCodeRequest {
|
||||
|
||||
private static final long PUSH_REQUEST_TIMEOUT_MS = 5000L;
|
||||
|
||||
private static final String TAG = Log.tag(RegistrationCodeRequest.class);
|
||||
|
||||
/**
|
||||
* Request a verification code to be sent according to the specified {@param mode}.
|
||||
*
|
||||
* The request will fire asynchronously, and exactly one of the methods on the {@param callback}
|
||||
* will be called.
|
||||
*/
|
||||
@SuppressLint("StaticFieldLeak")
|
||||
static void requestSmsVerificationCode(@NonNull Context context, @NonNull Credentials credentials, @Nullable String captchaToken, @NonNull Mode mode, @NonNull SmsVerificationCodeCallback callback) {
|
||||
Log.d(TAG, String.format("SMS Verification requested for %s captcha %s", credentials.getE164number(), captchaToken));
|
||||
|
||||
new AsyncTask<Void, Void, VerificationRequestResult>() {
|
||||
@Override
|
||||
protected @NonNull
|
||||
VerificationRequestResult doInBackground(Void... voids) {
|
||||
try {
|
||||
markAsVerifying(context);
|
||||
|
||||
Optional<String> fcmToken;
|
||||
|
||||
if (mode.isFcm()) {
|
||||
fcmToken = FcmUtil.getToken();
|
||||
} else {
|
||||
fcmToken = Optional.absent();
|
||||
}
|
||||
|
||||
SignalServiceAccountManager accountManager = AccountManagerFactory.createUnauthenticated(context, credentials.getE164number(), credentials.getPassword());
|
||||
|
||||
Optional<String> pushChallenge = PushChallengeRequest.getPushChallengeBlocking(accountManager, fcmToken, credentials.getE164number(), PUSH_REQUEST_TIMEOUT_MS);
|
||||
|
||||
if (mode == Mode.PHONE_CALL) {
|
||||
accountManager.requestVoiceVerificationCode(Locale.getDefault(), Optional.fromNullable(captchaToken), pushChallenge);
|
||||
} else {
|
||||
accountManager.requestSmsVerificationCode(mode.isSmsRetrieverSupported(), Optional.fromNullable(captchaToken), pushChallenge);
|
||||
}
|
||||
|
||||
return new VerificationRequestResult(fcmToken.orNull(), Optional.absent());
|
||||
} catch (IOException e) {
|
||||
org.thoughtcrime.securesms.logging.Log.w(TAG, "Error during account registration", e);
|
||||
return new VerificationRequestResult(null, Optional.of(e));
|
||||
}
|
||||
}
|
||||
|
||||
protected void onPostExecute(@NonNull VerificationRequestResult result) {
|
||||
if (result.exception.isPresent() && result.exception.get() instanceof CaptchaRequiredException) {
|
||||
callback.onNeedCaptcha();
|
||||
} else if (result.exception.isPresent()) {
|
||||
callback.onError();
|
||||
} else {
|
||||
callback.requestSent(result.fcmToken);
|
||||
}
|
||||
}
|
||||
}.executeOnExecutor(AsyncTask.THREAD_POOL_EXECUTOR);
|
||||
}
|
||||
|
||||
private static void markAsVerifying(Context context) {
|
||||
TextSecurePreferences.setVerifying(context, true);
|
||||
|
||||
TextSecurePreferences.setPushRegistered(context, false);
|
||||
}
|
||||
|
||||
private static class VerificationRequestResult {
|
||||
private final @Nullable String fcmToken;
|
||||
private final Optional<IOException> exception;
|
||||
|
||||
private VerificationRequestResult(@Nullable String fcmToken, Optional<IOException> exception) {
|
||||
this.fcmToken = fcmToken;
|
||||
this.exception = exception;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The mode by which a code is being requested.
|
||||
*/
|
||||
public enum Mode {
|
||||
|
||||
/**
|
||||
* Device supports FCM and SMS retrieval.
|
||||
*
|
||||
* The SMS sent will be formatted for automatic SMS retrieval.
|
||||
*/
|
||||
SMS_FCM_WITH_LISTENER(true, true),
|
||||
|
||||
/**
|
||||
* Device supports FCM but not SMS retrieval.
|
||||
*
|
||||
* The SMS sent will be not be specially formatted for automatic SMS retrieval.
|
||||
*/
|
||||
SMS_FCM_NO_LISTENER(true, false),
|
||||
|
||||
/**
|
||||
* Device does not support FCM and so also not SMS retrieval.
|
||||
*/
|
||||
SMS_NO_FCM(false, false),
|
||||
|
||||
/**
|
||||
* Device is requesting a phone call.
|
||||
*
|
||||
* Neither FCM or SMS retrieval is relevant in this mode.
|
||||
*/
|
||||
PHONE_CALL(false, false);
|
||||
|
||||
private final boolean fcm;
|
||||
private final boolean smsRetrieverSupported;
|
||||
|
||||
Mode(boolean fcm, boolean smsRetrieverSupported) {
|
||||
this.fcm = fcm;
|
||||
this.smsRetrieverSupported = smsRetrieverSupported;
|
||||
}
|
||||
|
||||
public boolean isFcm() {
|
||||
return fcm;
|
||||
}
|
||||
|
||||
public boolean isSmsRetrieverSupported() {
|
||||
return smsRetrieverSupported;
|
||||
}
|
||||
}
|
||||
|
||||
public interface SmsVerificationCodeCallback {
|
||||
|
||||
void onNeedCaptcha();
|
||||
|
||||
void requestSent(@Nullable String fcmToken);
|
||||
|
||||
void onError();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
package org.thoughtcrime.securesms.registration.service;
|
||||
|
||||
import android.app.Activity;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.annotation.Nullable;
|
||||
|
||||
import org.whispersystems.signalservice.internal.contacts.entities.TokenResponse;
|
||||
|
||||
import java.io.IOException;
|
||||
|
||||
public final class RegistrationService {
|
||||
|
||||
private final Credentials credentials;
|
||||
|
||||
private RegistrationService(@NonNull Credentials credentials) {
|
||||
this.credentials = credentials;
|
||||
}
|
||||
|
||||
public static RegistrationService getInstance(@NonNull String e164number, @NonNull String password) {
|
||||
return new RegistrationService(new Credentials(e164number, password));
|
||||
}
|
||||
|
||||
/**
|
||||
* See {@link RegistrationCodeRequest}.
|
||||
*/
|
||||
public void requestVerificationCode(@NonNull Activity activity,
|
||||
@NonNull RegistrationCodeRequest.Mode mode,
|
||||
@Nullable String captchaToken,
|
||||
@NonNull RegistrationCodeRequest.SmsVerificationCodeCallback callback)
|
||||
{
|
||||
RegistrationCodeRequest.requestSmsVerificationCode(activity, credentials, captchaToken, mode, callback);
|
||||
}
|
||||
|
||||
/**
|
||||
* See {@link CodeVerificationRequest}.
|
||||
*/
|
||||
public void verifyAccount(@NonNull Activity activity,
|
||||
@Nullable String fcmToken,
|
||||
@NonNull String code,
|
||||
@Nullable String pin,
|
||||
@Nullable String basicStorageCredentials,
|
||||
@Nullable TokenResponse tokenResponse,
|
||||
@NonNull CodeVerificationRequest.VerifyCallback callback)
|
||||
{
|
||||
CodeVerificationRequest.verifyAccount(activity, credentials, fcmToken, code, pin, basicStorageCredentials, tokenResponse, callback);
|
||||
}
|
||||
|
||||
public @Nullable TokenResponse getToken(@Nullable String basicStorageCredentials) throws IOException {
|
||||
return CodeVerificationRequest.getToken(basicStorageCredentials);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
package org.thoughtcrime.securesms.registration.viewmodel;
|
||||
|
||||
import android.os.Parcel;
|
||||
import android.os.Parcelable;
|
||||
|
||||
import androidx.annotation.MainThread;
|
||||
import androidx.annotation.NonNull;
|
||||
|
||||
import org.thoughtcrime.securesms.registration.service.RegistrationCodeRequest;
|
||||
|
||||
import java.util.HashMap;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
|
||||
public final class LocalCodeRequestRateLimiter implements Parcelable {
|
||||
|
||||
private final long timePeriod;
|
||||
private final Map<RegistrationCodeRequest.Mode, Data> dataMap;
|
||||
|
||||
public LocalCodeRequestRateLimiter(long timePeriod) {
|
||||
this.timePeriod = timePeriod;
|
||||
this.dataMap = new HashMap<>();
|
||||
}
|
||||
|
||||
@MainThread
|
||||
public boolean canRequest(@NonNull RegistrationCodeRequest.Mode mode, @NonNull String e164Number, long currentTime) {
|
||||
Data data = dataMap.get(mode);
|
||||
|
||||
return data == null || !data.limited(e164Number, currentTime);
|
||||
}
|
||||
|
||||
/**
|
||||
* Call this when the server has returned that it was successful in requesting a code via the specified mode.
|
||||
*/
|
||||
@MainThread
|
||||
public void onSuccessfulRequest(@NonNull RegistrationCodeRequest.Mode mode, @NonNull String e164Number, long currentTime) {
|
||||
dataMap.put(mode, new Data(e164Number, currentTime + timePeriod));
|
||||
}
|
||||
|
||||
/**
|
||||
* Call this if a mode was unsuccessful in sending.
|
||||
*/
|
||||
@MainThread
|
||||
public void onUnsuccessfulRequest() {
|
||||
dataMap.clear();
|
||||
}
|
||||
|
||||
static class Data {
|
||||
|
||||
final String e164Number;
|
||||
final long limitedUntil;
|
||||
|
||||
Data(@NonNull String e164Number, long limitedUntil) {
|
||||
this.e164Number = e164Number;
|
||||
this.limitedUntil = limitedUntil;
|
||||
}
|
||||
|
||||
boolean limited(String e164Number, long currentTime) {
|
||||
return this.e164Number.equals(e164Number) && currentTime < limitedUntil;
|
||||
}
|
||||
}
|
||||
|
||||
public static final Creator<LocalCodeRequestRateLimiter> CREATOR = new Creator<LocalCodeRequestRateLimiter>() {
|
||||
@Override
|
||||
public LocalCodeRequestRateLimiter createFromParcel(Parcel in) {
|
||||
long timePeriod = in.readLong();
|
||||
int numberOfMapEntries = in.readInt();
|
||||
|
||||
LocalCodeRequestRateLimiter localCodeRequestRateLimiter = new LocalCodeRequestRateLimiter(timePeriod);
|
||||
|
||||
for (int i = 0; i < numberOfMapEntries; i++) {
|
||||
RegistrationCodeRequest.Mode mode = RegistrationCodeRequest.Mode.values()[in.readInt()];
|
||||
String e164Number = in.readString();
|
||||
long limitedUntil = in.readLong();
|
||||
|
||||
localCodeRequestRateLimiter.dataMap.put(mode, new Data(Objects.requireNonNull(e164Number), limitedUntil));
|
||||
}
|
||||
return localCodeRequestRateLimiter;
|
||||
}
|
||||
|
||||
@Override
|
||||
public LocalCodeRequestRateLimiter[] newArray(int size) {
|
||||
return new LocalCodeRequestRateLimiter[size];
|
||||
}
|
||||
};
|
||||
|
||||
@Override
|
||||
public int describeContents() {
|
||||
return 0;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void writeToParcel(Parcel dest, int flags) {
|
||||
dest.writeLong(timePeriod);
|
||||
dest.writeInt(dataMap.size());
|
||||
|
||||
for (Map.Entry<RegistrationCodeRequest.Mode, Data> a : dataMap.entrySet()) {
|
||||
dest.writeInt(a.getKey().ordinal());
|
||||
dest.writeString(a.getValue().e164Number);
|
||||
dest.writeLong(a.getValue().limitedUntil);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,186 @@
|
||||
package org.thoughtcrime.securesms.registration.viewmodel;
|
||||
|
||||
import android.os.Parcel;
|
||||
import android.os.Parcelable;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.annotation.Nullable;
|
||||
|
||||
import com.google.i18n.phonenumbers.NumberParseException;
|
||||
import com.google.i18n.phonenumbers.PhoneNumberUtil;
|
||||
import com.google.i18n.phonenumbers.Phonenumber;
|
||||
|
||||
import org.whispersystems.signalservice.api.util.PhoneNumberFormatter;
|
||||
|
||||
import java.util.Objects;
|
||||
|
||||
public final class NumberViewState implements Parcelable {
|
||||
|
||||
public static final NumberViewState INITIAL = new Builder().build();
|
||||
|
||||
private final String selectedCountryName;
|
||||
private final int countryCode;
|
||||
private final long nationalNumber;
|
||||
|
||||
private NumberViewState(Builder builder) {
|
||||
this.selectedCountryName = builder.countryDisplayName;
|
||||
this.countryCode = builder.countryCode;
|
||||
this.nationalNumber = builder.nationalNumber;
|
||||
}
|
||||
|
||||
public Builder toBuilder() {
|
||||
return new Builder().countryCode(countryCode)
|
||||
.selectedCountryDisplayName(selectedCountryName)
|
||||
.nationalNumber(nationalNumber);
|
||||
}
|
||||
|
||||
public int getCountryCode() {
|
||||
return countryCode;
|
||||
}
|
||||
|
||||
public long getNationalNumber() {
|
||||
return nationalNumber;
|
||||
}
|
||||
|
||||
public String getCountryDisplayName() {
|
||||
if (selectedCountryName != null) {
|
||||
return selectedCountryName;
|
||||
}
|
||||
|
||||
PhoneNumberUtil util = PhoneNumberUtil.getInstance();
|
||||
|
||||
if (isValid()) {
|
||||
String actualCountry = getActualCountry(util, getE164Number());
|
||||
|
||||
if (actualCountry != null) {
|
||||
return actualCountry;
|
||||
}
|
||||
}
|
||||
|
||||
String regionCode = util.getRegionCodeForCountryCode(countryCode);
|
||||
return PhoneNumberFormatter.getRegionDisplayName(regionCode);
|
||||
}
|
||||
|
||||
/**
|
||||
* Finds actual name of region from a valid number. So for example +1 might map to US or Canada or other territories.
|
||||
*/
|
||||
private static @Nullable String getActualCountry(@NonNull PhoneNumberUtil util, @NonNull String e164Number) {
|
||||
try {
|
||||
Phonenumber.PhoneNumber phoneNumber = getPhoneNumber(util, e164Number);
|
||||
String regionCode = util.getRegionCodeForNumber(phoneNumber);
|
||||
|
||||
if (regionCode != null) {
|
||||
return PhoneNumberFormatter.getRegionDisplayName(regionCode);
|
||||
}
|
||||
|
||||
} catch (NumberParseException e) {
|
||||
return null;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
public boolean isValid() {
|
||||
return PhoneNumberFormatter.isValidNumber(getE164Number(), Integer.toString(getCountryCode()));
|
||||
}
|
||||
|
||||
@Override
|
||||
public int hashCode() {
|
||||
int hash = countryCode;
|
||||
hash *= 31;
|
||||
hash += (int) (nationalNumber ^ (nationalNumber >>> 32));
|
||||
hash *= 31;
|
||||
hash += selectedCountryName != null ? selectedCountryName.hashCode() : 0;
|
||||
return hash;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean equals(@Nullable Object obj) {
|
||||
if (obj == null) return false;
|
||||
if (obj.getClass() != getClass()) return false;
|
||||
|
||||
NumberViewState other = (NumberViewState) obj;
|
||||
|
||||
return other.countryCode == countryCode &&
|
||||
other.nationalNumber == nationalNumber &&
|
||||
Objects.equals(other.selectedCountryName, selectedCountryName);
|
||||
}
|
||||
|
||||
public String getE164Number() {
|
||||
return getConfiguredE164Number(countryCode, nationalNumber);
|
||||
}
|
||||
|
||||
public String getFullFormattedNumber() {
|
||||
return formatNumber(PhoneNumberUtil.getInstance(), getE164Number());
|
||||
}
|
||||
|
||||
private static String formatNumber(@NonNull PhoneNumberUtil util, @NonNull String e164Number) {
|
||||
try {
|
||||
Phonenumber.PhoneNumber number = getPhoneNumber(util, e164Number);
|
||||
return util.format(number, PhoneNumberUtil.PhoneNumberFormat.INTERNATIONAL);
|
||||
} catch (NumberParseException e) {
|
||||
return e164Number;
|
||||
}
|
||||
}
|
||||
|
||||
private static String getConfiguredE164Number(int countryCode, long number) {
|
||||
return PhoneNumberFormatter.formatE164(String.valueOf(countryCode), String.valueOf(number));
|
||||
}
|
||||
|
||||
private static Phonenumber.PhoneNumber getPhoneNumber(@NonNull PhoneNumberUtil util, @NonNull String e164Number)
|
||||
throws NumberParseException
|
||||
{
|
||||
return util.parse(e164Number, null);
|
||||
}
|
||||
|
||||
public static class Builder {
|
||||
private String countryDisplayName;
|
||||
private int countryCode;
|
||||
private long nationalNumber;
|
||||
|
||||
public Builder countryCode(int countryCode) {
|
||||
this.countryCode = countryCode;
|
||||
return this;
|
||||
}
|
||||
|
||||
public Builder selectedCountryDisplayName(String countryDisplayName) {
|
||||
this.countryDisplayName = countryDisplayName;
|
||||
return this;
|
||||
}
|
||||
|
||||
public Builder nationalNumber(long nationalNumber) {
|
||||
this.nationalNumber = nationalNumber;
|
||||
return this;
|
||||
}
|
||||
|
||||
public NumberViewState build() {
|
||||
return new NumberViewState(this);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public int describeContents() {
|
||||
return 0;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void writeToParcel(Parcel parcel, int i) {
|
||||
parcel.writeString(selectedCountryName);
|
||||
parcel.writeInt(countryCode);
|
||||
parcel.writeLong(nationalNumber);
|
||||
}
|
||||
|
||||
public static final Creator<NumberViewState> CREATOR = new Creator<NumberViewState>() {
|
||||
@Override
|
||||
public NumberViewState createFromParcel(Parcel in) {
|
||||
return new Builder().selectedCountryDisplayName(in.readString())
|
||||
.countryCode(in.readInt())
|
||||
.nationalNumber(in.readLong())
|
||||
.build();
|
||||
}
|
||||
|
||||
@Override
|
||||
public NumberViewState[] newArray(int size) {
|
||||
return new NumberViewState[size];
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,204 @@
|
||||
package org.thoughtcrime.securesms.registration.viewmodel;
|
||||
|
||||
import androidx.annotation.MainThread;
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.annotation.Nullable;
|
||||
import androidx.lifecycle.LiveData;
|
||||
import androidx.lifecycle.MutableLiveData;
|
||||
import androidx.lifecycle.SavedStateHandle;
|
||||
import androidx.lifecycle.Transformations;
|
||||
import androidx.lifecycle.ViewModel;
|
||||
|
||||
import org.thoughtcrime.securesms.logging.Log;
|
||||
import org.thoughtcrime.securesms.registration.service.RegistrationService;
|
||||
import org.thoughtcrime.securesms.util.Util;
|
||||
import org.thoughtcrime.securesms.util.concurrent.SimpleTask;
|
||||
import org.thoughtcrime.securesms.util.livedata.LiveDataPair;
|
||||
import org.whispersystems.libsignal.util.Pair;
|
||||
import org.whispersystems.signalservice.internal.contacts.entities.TokenResponse;
|
||||
import org.whispersystems.signalservice.internal.util.JsonUtil;
|
||||
|
||||
import java.io.IOException;
|
||||
|
||||
public final class RegistrationViewModel extends ViewModel {
|
||||
|
||||
private static final String TAG = Log.tag(RegistrationViewModel.class);
|
||||
|
||||
private final String secret;
|
||||
private final MutableLiveData<NumberViewState> number;
|
||||
private final MutableLiveData<String> textCodeEntered;
|
||||
private final MutableLiveData<String> captchaToken;
|
||||
private final MutableLiveData<String> fcmToken;
|
||||
private final MutableLiveData<String> basicStorageCredentials;
|
||||
private final MutableLiveData<Boolean> restoreFlowShown;
|
||||
private final MutableLiveData<Integer> successfulCodeRequestAttempts;
|
||||
private final MutableLiveData<LocalCodeRequestRateLimiter> requestLimiter;
|
||||
private final MutableLiveData<String> keyBackupcurrentTokenJson;
|
||||
private final LiveData<TokenResponse> keyBackupcurrentToken;
|
||||
private final LiveData<Pair<TokenResponse, String>> tokenResponseCredentialsPair;
|
||||
|
||||
public RegistrationViewModel(@NonNull SavedStateHandle savedStateHandle) {
|
||||
secret = loadValue(savedStateHandle, "REGISTRATION_SECRET", Util.getSecret(18));
|
||||
|
||||
number = savedStateHandle.getLiveData("NUMBER", NumberViewState.INITIAL);
|
||||
textCodeEntered = savedStateHandle.getLiveData("TEXT_CODE_ENTERED", "");
|
||||
captchaToken = savedStateHandle.getLiveData("CAPTCHA");
|
||||
fcmToken = savedStateHandle.getLiveData("FCM_TOKEN");
|
||||
basicStorageCredentials = savedStateHandle.getLiveData("BASIC_STORAGE_CREDENTIALS");
|
||||
restoreFlowShown = savedStateHandle.getLiveData("RESTORE_FLOW_SHOWN", false);
|
||||
successfulCodeRequestAttempts = savedStateHandle.getLiveData("SUCCESSFUL_CODE_REQUEST_ATTEMPTS", 0);
|
||||
requestLimiter = savedStateHandle.getLiveData("REQUEST_RATE_LIMITER", new LocalCodeRequestRateLimiter(60_000));
|
||||
keyBackupcurrentTokenJson = savedStateHandle.getLiveData("KBS_TOKEN");
|
||||
|
||||
keyBackupcurrentToken = Transformations.map(keyBackupcurrentTokenJson, json ->
|
||||
{
|
||||
if (json == null) return null;
|
||||
try {
|
||||
return JsonUtil.fromJson(json, TokenResponse.class);
|
||||
} catch (IOException e) {
|
||||
Log.w(TAG, e);
|
||||
return null;
|
||||
}
|
||||
});
|
||||
|
||||
tokenResponseCredentialsPair = new LiveDataPair<>(keyBackupcurrentToken, basicStorageCredentials);
|
||||
}
|
||||
|
||||
private static <T> T loadValue(@NonNull SavedStateHandle savedStateHandle, @NonNull String key, @NonNull T initialValue) {
|
||||
if (!savedStateHandle.contains(key)) {
|
||||
savedStateHandle.set(key, initialValue);
|
||||
}
|
||||
return savedStateHandle.get(key);
|
||||
}
|
||||
|
||||
public @NonNull NumberViewState getNumber() {
|
||||
//noinspection ConstantConditions Live data was given an initial value
|
||||
return number.getValue();
|
||||
}
|
||||
|
||||
public @NonNull LiveData<NumberViewState> getLiveNumber() {
|
||||
return number;
|
||||
}
|
||||
|
||||
public @NonNull String getTextCodeEntered() {
|
||||
//noinspection ConstantConditions Live data was given an initial value
|
||||
return textCodeEntered.getValue();
|
||||
}
|
||||
|
||||
public String getCaptchaToken() {
|
||||
return captchaToken.getValue();
|
||||
}
|
||||
|
||||
public boolean hasCaptchaToken() {
|
||||
return getCaptchaToken() != null;
|
||||
}
|
||||
|
||||
public String getRegistrationSecret() {
|
||||
return secret;
|
||||
}
|
||||
|
||||
public void onCaptchaResponse(String captchaToken) {
|
||||
this.captchaToken.setValue(captchaToken);
|
||||
}
|
||||
|
||||
public void clearCaptchaResponse() {
|
||||
captchaToken.setValue(null);
|
||||
}
|
||||
|
||||
public void onCountrySelected(@Nullable String selectedCountryName, int countryCode) {
|
||||
setViewState(getNumber().toBuilder()
|
||||
.selectedCountryDisplayName(selectedCountryName)
|
||||
.countryCode(countryCode).build());
|
||||
}
|
||||
|
||||
public void setNationalNumber(long number) {
|
||||
NumberViewState numberViewState = getNumber().toBuilder().nationalNumber(number).build();
|
||||
setViewState(numberViewState);
|
||||
}
|
||||
|
||||
private void setViewState(NumberViewState numberViewState) {
|
||||
if (!numberViewState.equals(getNumber())) {
|
||||
number.setValue(numberViewState);
|
||||
}
|
||||
}
|
||||
|
||||
@MainThread
|
||||
public void onVerificationCodeEntered(String code) {
|
||||
textCodeEntered.setValue(code);
|
||||
}
|
||||
|
||||
public void onNumberDetected(int countryCode, long nationalNumber) {
|
||||
setViewState(getNumber().toBuilder()
|
||||
.countryCode(countryCode)
|
||||
.nationalNumber(nationalNumber)
|
||||
.build());
|
||||
}
|
||||
|
||||
public String getFcmToken() {
|
||||
return fcmToken.getValue();
|
||||
}
|
||||
|
||||
@MainThread
|
||||
public void setFcmToken(@Nullable String fcmToken) {
|
||||
this.fcmToken.setValue(fcmToken);
|
||||
}
|
||||
|
||||
public void setWelcomeSkippedOnRestore() {
|
||||
restoreFlowShown.setValue(true);
|
||||
}
|
||||
|
||||
public boolean hasRestoreFlowBeenShown() {
|
||||
//noinspection ConstantConditions Live data was given an initial value
|
||||
return restoreFlowShown.getValue();
|
||||
}
|
||||
|
||||
public void markASuccessfulAttempt() {
|
||||
//noinspection ConstantConditions Live data was given an initial value
|
||||
successfulCodeRequestAttempts.setValue(successfulCodeRequestAttempts.getValue() + 1);
|
||||
}
|
||||
|
||||
public LiveData<Integer> getSuccessfulCodeRequestAttempts() {
|
||||
return successfulCodeRequestAttempts;
|
||||
}
|
||||
|
||||
public @NonNull LocalCodeRequestRateLimiter getRequestLimiter() {
|
||||
//noinspection ConstantConditions Live data was given an initial value
|
||||
return requestLimiter.getValue();
|
||||
}
|
||||
|
||||
public void updateLimiter() {
|
||||
requestLimiter.setValue(requestLimiter.getValue());
|
||||
}
|
||||
|
||||
public void setStorageCredentials(@Nullable String storageCredentials) {
|
||||
basicStorageCredentials.setValue(storageCredentials);
|
||||
}
|
||||
|
||||
public @Nullable String getBasicStorageCredentials() {
|
||||
return basicStorageCredentials.getValue();
|
||||
}
|
||||
|
||||
public @Nullable TokenResponse getKeyBackupCurrentToken() {
|
||||
return keyBackupcurrentToken.getValue();
|
||||
}
|
||||
|
||||
public void setKeyBackupCurrentToken(TokenResponse tokenResponse) {
|
||||
keyBackupcurrentTokenJson.setValue(tokenResponse == null ? null : JsonUtil.toJson(tokenResponse));
|
||||
}
|
||||
|
||||
public LiveData<Pair<TokenResponse, String>> getTokenResponseCredentialsPair() {
|
||||
return tokenResponseCredentialsPair;
|
||||
}
|
||||
|
||||
public void onRegistrationLockFragmentCreate() {
|
||||
SimpleTask.run(() -> {
|
||||
RegistrationService registrationService = RegistrationService.getInstance(getNumber().getE164Number(), getRegistrationSecret());
|
||||
try {
|
||||
return registrationService.getToken(getBasicStorageCredentials());
|
||||
} catch (IOException e) {
|
||||
Log.w(TAG, e);
|
||||
return null;
|
||||
}
|
||||
}, this::setKeyBackupCurrentToken);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user