diff --git a/README.md b/README.md
index 9ce58753c6..666e9c0807 100644
--- a/README.md
+++ b/README.md
@@ -66,6 +66,13 @@ If this number is lower than the [latest release's version
number](https://github.com/google/libphonenumber/releases), we are between
releases and the demo may be at either version.
+### Demo App
+
+There is a demo Android App called [E.164 Formatter](java/demoapp) in this
+repository. The purpose of this App is to show an example of how the library can
+be used in a real-life situation, in this case specifically in an Android App
+using Java.
+
## JavaScript
The [JavaScript
diff --git a/java/demoapp/README.md b/java/demoapp/README.md
new file mode 100644
index 0000000000..94d4db0caa
--- /dev/null
+++ b/java/demoapp/README.md
@@ -0,0 +1,51 @@
+# Demo App: E.164 Formatter
+
+## What is this?
+
+The E.164 Formatter is an Android App that reads all the phone numbers stored in
+the device's contacts and processes them using the
+[LibPhoneNumber](https://github.com/google/libphonenumber) Library.
+
+The purpose of this App is to show an example of how LPN can be used in a
+real-life situation, in this case specifically in an Android App using Java.
+
+## How can I install the app?
+
+You can use the source code to build the app yourself.
+
+## Where is the LPN code located?
+
+The code using LPN is located in
+[`PhoneNumberFormatting#formatPhoneNumberInApp(PhoneNumberInApp, String,
+boolean)`](app/src/main/java/com/google/phonenumbers/demoapp/phonenumbers/PhoneNumberFormatting.java#L31)
+.
+
+## How does the app work?
+
+On the start screen, the app asks the user for a country to later use when
+trying to convert the phone numbers to E.164. After the user starts the process
+and grants permission to read and write contacts, the app shows the user two
+lists in the UI.
+
+**List 1: Formattable**
+
+Contains all the phone number that are parsable by LPN, are not short numbers,
+and are valid numbers and can be reformatted to E.164 using the country selected
+on the start screen. In other words, valid locally formatted phone numbers of
+the selected country (e.g. `044 668 18 00` if the selected country is
+Switzerland).
+
+Each list item (= one phone number in the device's contacts) has a checkbox.
+With the click of the button "Update selected" under the list, the app replaces
+the phone numbers of the checked list elements in the contacts with the
+suggested E.164 replacements.
+
+**List 2: Not formattable**
+
+Shows all the phone number that do not fit the criteria of List 1, each tagged
+with one of the following errors:
+
+* Parsing error
+* Short number (e.g. `112`)
+* Invalid number (e.g. `+41446681800123`)
+* Already E.164 (e.g. `+41446681800`)
diff --git a/java/demoapp/app/build.gradle b/java/demoapp/app/build.gradle
new file mode 100644
index 0000000000..9f5344e21c
--- /dev/null
+++ b/java/demoapp/app/build.gradle
@@ -0,0 +1,45 @@
+plugins {
+ id 'com.android.application'
+}
+
+android {
+ namespace 'com.google.phonenumbers.demoapp'
+ compileSdk 33
+
+ defaultConfig {
+ applicationId "com.google.phonenumbers.demoapp"
+ minSdk 31
+ targetSdk 33
+ versionCode 1
+ versionName "1.0"
+
+ testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
+ }
+
+ buildTypes {
+ release {
+ debuggable false
+ minifyEnabled true
+ shrinkResources true
+ proguardFiles getDefaultProguardFile(
+ 'proguard-android-optimize.txt')
+ }
+
+ debug {
+ debuggable true
+ }
+ }
+
+ compileOptions {
+ sourceCompatibility JavaVersion.VERSION_1_8
+ targetCompatibility JavaVersion.VERSION_1_8
+ }
+}
+
+dependencies {
+ implementation 'androidx.appcompat:appcompat:1.6.1'
+ implementation 'androidx.constraintlayout:constraintlayout:2.1.4'
+ implementation 'com.google.android.material:material:1.8.0'
+ implementation 'com.googlecode.libphonenumber:libphonenumber:8.13.5'
+ testImplementation 'junit:junit:4.13.2'
+}
diff --git a/java/demoapp/app/src/main/AndroidManifest.xml b/java/demoapp/app/src/main/AndroidManifest.xml
new file mode 100644
index 0000000000..32b365efd6
--- /dev/null
+++ b/java/demoapp/app/src/main/AndroidManifest.xml
@@ -0,0 +1,39 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/java/demoapp/app/src/main/java/com/google/phonenumbers/demoapp/MyApplication.java b/java/demoapp/app/src/main/java/com/google/phonenumbers/demoapp/MyApplication.java
new file mode 100644
index 0000000000..f15889f235
--- /dev/null
+++ b/java/demoapp/app/src/main/java/com/google/phonenumbers/demoapp/MyApplication.java
@@ -0,0 +1,16 @@
+package com.google.phonenumbers.demoapp;
+
+import android.app.Application;
+import com.google.android.material.color.DynamicColors;
+
+/**
+ * Used instead of default {@link Application} instance. Only difference is that this implementation
+ * enabled Dynamic Colors for the app.
+ */
+public class MyApplication extends Application {
+ @Override
+ public void onCreate() {
+ super.onCreate();
+ DynamicColors.applyToActivitiesIfAvailable(this);
+ }
+}
diff --git a/java/demoapp/app/src/main/java/com/google/phonenumbers/demoapp/contacts/ContactsPermissionManagement.java b/java/demoapp/app/src/main/java/com/google/phonenumbers/demoapp/contacts/ContactsPermissionManagement.java
new file mode 100644
index 0000000000..cc90c080db
--- /dev/null
+++ b/java/demoapp/app/src/main/java/com/google/phonenumbers/demoapp/contacts/ContactsPermissionManagement.java
@@ -0,0 +1,161 @@
+package com.google.phonenumbers.demoapp.contacts;
+
+import static android.content.Context.MODE_PRIVATE;
+
+import android.Manifest.permission;
+import android.app.Activity;
+import android.content.Context;
+import android.content.Intent;
+import android.content.SharedPreferences;
+import android.content.pm.PackageManager;
+import android.net.Uri;
+import androidx.core.app.ActivityCompat;
+import androidx.core.content.ContextCompat;
+
+/**
+ * Handles everything related to the contacts permissions ({@link permission#READ_CONTACTS} and
+ * {@link permission#WRITE_CONTACTS}) and the requesting process to grant the permissions.
+ */
+public class ContactsPermissionManagement {
+
+ public static final int CONTACTS_PERMISSION_REQUEST_CODE = 0;
+
+ private static final String SHARED_PREFS_NAME = "contacts-permission-management";
+ private static final String NUMBER_OF_CONTACTS_PERMISSION_DENIALS_KEY =
+ "NUMBER_OF_CONTACTS_PERMISSION_DENIALS";
+
+ private ContactsPermissionManagement() {}
+
+ /**
+ * Returns the current state of the permissions granting as {@link PermissionState}.
+ *
+ * @param activity Activity of the app
+ * @return {@link PermissionState} of the permissions granting
+ */
+ public static PermissionState getState(Activity activity) {
+ if (isGranted(activity.getApplicationContext())) {
+ return PermissionState.ALREADY_GRANTED;
+ }
+ if (!shouldPermissionBeRequestedInApp(activity.getApplicationContext())) {
+ return PermissionState.NEEDS_GRANT_IN_SETTINGS;
+ }
+ if (shouldShowRationale(activity)) {
+ return PermissionState.SHOW_RATIONALE;
+ }
+ return PermissionState.NEEDS_REQUEST;
+ }
+
+ /**
+ * Returns whether the contacts permissions ({@link permission#READ_CONTACTS} and {@link
+ * permission#WRITE_CONTACTS}) are granted for the param {@code context}.
+ *
+ * @param context Context of the app
+ * @return boolean whether contacts permissions are granted
+ */
+ public static boolean isGranted(Context context) {
+ if (ContextCompat.checkSelfPermission(context, permission.READ_CONTACTS)
+ == PackageManager.PERMISSION_DENIED) {
+ return false;
+ }
+ return ContextCompat.checkSelfPermission(context, permission.WRITE_CONTACTS)
+ != PackageManager.PERMISSION_DENIED;
+ }
+
+ /**
+ * Returns whether the permissions should be requested directly in the app or not. Specifically
+ * returns true if less than 2 denials happened since the app installation.
+ *
+ * @param context Context of the app
+ * @return boolean whether the permissions should be requested directly in the app
+ */
+ private static boolean shouldPermissionBeRequestedInApp(Context context) {
+ return getNumberOfDenials(context) < 2;
+ }
+
+ /**
+ * Returns the number of times the permission dialog has been denied since the app installation.
+ * Dismissing the permission dialog instead of answering is considered a denial.
+ *
+ * @param context Context of the app
+ * @return int number of times the permission has been denied
+ */
+ private static int getNumberOfDenials(Context context) {
+ SharedPreferences preferences = context.getSharedPreferences(SHARED_PREFS_NAME, MODE_PRIVATE);
+ return preferences.getInt(NUMBER_OF_CONTACTS_PERMISSION_DENIALS_KEY, 0);
+ }
+
+ /**
+ * Adds 1 to the number of denials since the app installation. Should be called every time the
+ * user denies the permission (in the dialog). Dismissing the permission dialog instead of
+ * answering is considered a denial.
+ *
+ * @param context Context of the app
+ */
+ public static void addOneToNumberOfDenials(Context context) {
+ SharedPreferences.Editor editor =
+ context.getSharedPreferences(SHARED_PREFS_NAME, MODE_PRIVATE).edit();
+ editor.putInt(NUMBER_OF_CONTACTS_PERMISSION_DENIALS_KEY, getNumberOfDenials(context) + 1);
+ editor.apply();
+ }
+
+ /**
+ * Returns whether a rational should be shown explaining why the app requests these permissions
+ * (before requesting them).
+ *
+ * @param activity Activity of the app
+ * @return boolean whether a rational should be shown
+ */
+ private static boolean shouldShowRationale(Activity activity) {
+ if (ActivityCompat.shouldShowRequestPermissionRationale(activity, permission.READ_CONTACTS)) {
+ return true;
+ }
+ return ActivityCompat.shouldShowRequestPermissionRationale(activity, permission.WRITE_CONTACTS);
+ }
+
+ /**
+ * Requests the contact permissions ({@link permission#READ_CONTACTS} and {@link
+ * permission#WRITE_CONTACTS}) in the param {@code activity} with the request code {@link
+ * ContactsPermissionManagement#CONTACTS_PERMISSION_REQUEST_CODE}.
+ *
+ * @param activity Activity of the app
+ */
+ public static void request(Activity activity) {
+ activity.requestPermissions(
+ new String[] {permission.READ_CONTACTS, permission.WRITE_CONTACTS},
+ CONTACTS_PERMISSION_REQUEST_CODE);
+ }
+
+ /**
+ * Opens the system settings (app details page) if the app can. Special cases that can not open
+ * the system settings are for example emulators without Play Store installed.
+ *
+ * @param activity Activity of the app
+ */
+ public static void openSystemSettings(Activity activity) {
+ Intent intent =
+ new Intent(
+ android.provider.Settings.ACTION_APPLICATION_DETAILS_SETTINGS,
+ Uri.parse("package:" + activity.getPackageName()));
+ activity.startActivity(intent);
+ }
+
+ /** Represents the different states the permissions granting process can be at. */
+ public enum PermissionState {
+ /** The permissions are already granted. The action requiring the permissions can be started. */
+ ALREADY_GRANTED,
+ /**
+ * The permissions are not granted, but can be requested directly (without showing a rationale).
+ */
+ NEEDS_REQUEST,
+ /**
+ * The permissions are not granted and a rationale should be shown explaining why the app
+ * requests the permissions before requesting them (directly in the app).
+ */
+ SHOW_RATIONALE,
+ /**
+ * The permissions are not granted and can not be granted directly in the app. The user has to
+ * grant permissions in the system settings instead.
+ */
+ NEEDS_GRANT_IN_SETTINGS
+ }
+}
diff --git a/java/demoapp/app/src/main/java/com/google/phonenumbers/demoapp/contacts/ContactsRead.java b/java/demoapp/app/src/main/java/com/google/phonenumbers/demoapp/contacts/ContactsRead.java
new file mode 100644
index 0000000000..068da30951
--- /dev/null
+++ b/java/demoapp/app/src/main/java/com/google/phonenumbers/demoapp/contacts/ContactsRead.java
@@ -0,0 +1,62 @@
+package com.google.phonenumbers.demoapp.contacts;
+
+import android.content.ContentResolver;
+import android.content.Context;
+import android.database.Cursor;
+import android.provider.ContactsContract;
+import android.provider.ContactsContract.CommonDataKinds.Phone;
+import com.google.phonenumbers.demoapp.phonenumbers.PhoneNumberInApp;
+import java.util.ArrayList;
+import java.util.Collections;
+
+/** Handles everything related to reading the device contacts. */
+public class ContactsRead {
+
+ private ContactsRead() {}
+
+ /**
+ * Reads all phone numbers in the device's contacts and return them as a list of {@link
+ * PhoneNumberInApp}s ascending sorted by the contact name. An empty list is also returned if the
+ * app has no permission to read contacts or an error occurred while doing so
+ *
+ * @param context Context of the app
+ * @return ArrayList of all phone numbers in the device's contacts, also empty if the app has no
+ * permission to read contacts or an error occurred while doing so
+ */
+ public static ArrayList getAllPhoneNumbersSorted(Context context) {
+ ArrayList phoneNumbers = new ArrayList<>();
+
+ if (!ContactsPermissionManagement.isGranted(context)) {
+ return phoneNumbers;
+ }
+
+ ContentResolver cr = context.getContentResolver();
+ // Only query for contacts with phone number(s).
+ Cursor cursor =
+ cr.query(ContactsContract.CommonDataKinds.Phone.CONTENT_URI, null, null, null, null);
+ // If query doesn't work as intended.
+ if (cursor == null) {
+ return phoneNumbers;
+ }
+
+ while (cursor.moveToNext()) {
+ // ID to identify the phone number entry in the contacts (can be used to update in contacts).
+ int idIndex = cursor.getColumnIndex(Phone._ID);
+ String id = idIndex != -1 ? cursor.getString(idIndex) : "";
+
+ int contactNameIndex = cursor.getColumnIndex(Phone.DISPLAY_NAME);
+ String contactName = contactNameIndex != -1 ? cursor.getString(contactNameIndex) : "";
+
+ int originalPhoneNumberIndex = cursor.getColumnIndex(Phone.NUMBER);
+ String originalPhoneNumber =
+ originalPhoneNumberIndex != -1 ? cursor.getString(originalPhoneNumberIndex) : "";
+
+ PhoneNumberInApp phoneNumberInApp =
+ new PhoneNumberInApp(id, contactName, originalPhoneNumber);
+ phoneNumbers.add(phoneNumberInApp);
+ }
+ cursor.close();
+ Collections.sort(phoneNumbers);
+ return phoneNumbers;
+ }
+}
diff --git a/java/demoapp/app/src/main/java/com/google/phonenumbers/demoapp/contacts/ContactsWrite.java b/java/demoapp/app/src/main/java/com/google/phonenumbers/demoapp/contacts/ContactsWrite.java
new file mode 100644
index 0000000000..434ba55a89
--- /dev/null
+++ b/java/demoapp/app/src/main/java/com/google/phonenumbers/demoapp/contacts/ContactsWrite.java
@@ -0,0 +1,59 @@
+package com.google.phonenumbers.demoapp.contacts;
+
+import android.content.ContentProviderOperation;
+import android.content.Context;
+import android.content.OperationApplicationException;
+import android.os.RemoteException;
+import android.provider.ContactsContract;
+import android.provider.ContactsContract.CommonDataKinds.Phone;
+import com.google.phonenumbers.demoapp.phonenumbers.PhoneNumberInApp;
+import java.util.ArrayList;
+
+/** Handles everything related to writing the device contacts. */
+public class ContactsWrite {
+
+ private ContactsWrite() {}
+
+ /**
+ * Attempts to update all phone numbers in param {@code phoneNumbers} in the device's contacts.
+ * {@link PhoneNumberInApp#shouldContactBeUpdated()} is not called in this method and should be
+ * checked while creating the param {@code phoneNumbers}.
+ *
+ * @param phoneNumbers ArrayList of all phone numbers to update
+ * @param context Context of the app
+ * @return boolean whether operation was successful
+ */
+ public static boolean updatePhoneNumbers(
+ ArrayList phoneNumbers, Context context) {
+ if (!ContactsPermissionManagement.isGranted(context)) {
+ return false;
+ }
+
+ // Create a list of operations to only have to apply one batch.
+ ArrayList contentProviderOperations = new ArrayList<>();
+
+ for (PhoneNumberInApp phoneNumber : phoneNumbers) {
+ // Identify the exact phone number entry to update.
+ String whereConditionBase = Phone._ID + " = ? AND " + ContactsContract.Data.MIMETYPE + " = ?";
+ String[] whereConditionParams =
+ new String[] {
+ phoneNumber.getId(), ContactsContract.CommonDataKinds.Phone.CONTENT_ITEM_TYPE
+ };
+
+ contentProviderOperations.add(
+ ContentProviderOperation.newUpdate(ContactsContract.Data.CONTENT_URI)
+ .withSelection(whereConditionBase, whereConditionParams)
+ .withValue(Phone.NUMBER, phoneNumber.getFormattedPhoneNumber())
+ .build());
+ }
+
+ try {
+ context
+ .getContentResolver()
+ .applyBatch(ContactsContract.AUTHORITY, contentProviderOperations);
+ } catch (OperationApplicationException | RemoteException e) {
+ return false;
+ }
+ return true;
+ }
+}
diff --git a/java/demoapp/app/src/main/java/com/google/phonenumbers/demoapp/main/CountryDropdown.java b/java/demoapp/app/src/main/java/com/google/phonenumbers/demoapp/main/CountryDropdown.java
new file mode 100644
index 0000000000..5b36e3c420
--- /dev/null
+++ b/java/demoapp/app/src/main/java/com/google/phonenumbers/demoapp/main/CountryDropdown.java
@@ -0,0 +1,203 @@
+package com.google.phonenumbers.demoapp.main;
+
+import android.content.Context;
+import android.util.AttributeSet;
+import android.view.KeyEvent;
+import android.widget.ArrayAdapter;
+import android.widget.AutoCompleteTextView;
+import android.widget.LinearLayout;
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+import com.google.android.material.textfield.TextInputLayout;
+import com.google.i18n.phonenumbers.PhoneNumberUtil;
+import com.google.phonenumbers.demoapp.R;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Locale;
+import java.util.Map;
+import java.util.Set;
+
+/**
+ * A component containing a searchable dropdown input populated with all regions {@link
+ * PhoneNumberUtil} supports. Dropdown items are of format {@code [countryName] ([nameCode]) -
+ * +[callingCode]} (e.g. {@code Switzerland (CH) - +41}). Method provides access to the name code
+ * (e.g. {@code CH}) of the current input. Name code: ISO 3166-1 alpha-2 country code (e.g.
+ * {@code CH}). Calling code: ITU-T E.164 assigned
+ * country code (e.g. {@code 41}).
+ */
+public class CountryDropdown extends LinearLayout {
+
+ /**
+ * Map containing keys of format {@code [countryName] ([nameCode]) - +[callingCode]} (e.g. {@code
+ * Switzerland (CH) - +41}), and name codes (e.g. {@code CH}) as values.
+ */
+ private static final Map countryLabelMapNameCode = new HashMap<>();
+ /** Ascending sorted list of the keys in {@link CountryDropdown#countryLabelMapNameCode}. */
+ private static final List countryLabelSorted = new ArrayList<>();
+
+ private final TextInputLayout input;
+ private final AutoCompleteTextView inputEditText;
+
+ /** The name code of the current input. */
+ private String nameCode;
+
+ public CountryDropdown(@NonNull Context context, @Nullable AttributeSet attrs) {
+ super(context, attrs);
+ inflate(getContext(), R.layout.country_dropdown, this);
+ input = findViewById(R.id.country_dropdown_input);
+ inputEditText = findViewById(R.id.country_dropdown_input_edit_text);
+
+ inputEditText.setOnKeyListener(
+ (v, keyCode, event) -> {
+ // If the DEL key is used and the input was a valid dropdown option, clear the input
+ // completely
+ if (keyCode == KeyEvent.KEYCODE_DEL && setNameCodeForInput()) {
+ inputEditText.setText("");
+ }
+ // Disable the error state when editing the input after the validation revealed an error
+ if (input.isErrorEnabled()) {
+ disableInputError();
+ }
+ return false;
+ });
+
+ populateCountryLabelMapNameCode();
+ setAdapter();
+ }
+
+ /**
+ * Populates {@link CountryDropdown#countryLabelMapNameCode} with all regions {@link
+ * PhoneNumberUtil} supports if not populated yet.
+ */
+ private void populateCountryLabelMapNameCode() {
+ if (!countryLabelMapNameCode.isEmpty()) {
+ return;
+ }
+
+ Set supportedNameCodes = PhoneNumberUtil.getInstance().getSupportedRegions();
+ for (String nameCode : supportedNameCodes) {
+ String countryLabel = getCountryLabelForNameCode(nameCode);
+ countryLabelMapNameCode.put(countryLabel, nameCode);
+ }
+ }
+
+ /**
+ * Returns the label of format {@code [countryName] ([nameCode]) - +[callingCode]} (e.g. {@code
+ * Switzerland (CH) - +41}) for the param {@code nameCode}.
+ *
+ * @param nameCode String in format of a name code (e.g. {@code CH})
+ * @return String label of format {@code [countryName] ([nameCode]) - +[callingCode]} (e.g. {@code
+ * Switzerland (CH) - +41})
+ */
+ private String getCountryLabelForNameCode(String nameCode) {
+ Locale locale = new Locale("en", nameCode);
+ String countryName = locale.getDisplayCountry();
+ int callingCode =
+ PhoneNumberUtil.getInstance().getCountryCodeForRegion(nameCode.toUpperCase(Locale.ROOT));
+
+ return countryName + " (" + nameCode.toUpperCase(Locale.ROOT) + ") - +" + callingCode;
+ }
+
+ /**
+ * Populates {@link CountryDropdown#countryLabelSorted} with the ascending sorted keys of {@link
+ * CountryDropdown#countryLabelMapNameCode} if not populated yet. Then sets an {@link
+ * ArrayAdapter} with {@link CountryDropdown#countryLabelSorted} for the dropdown to show the
+ * list.
+ */
+ private void setAdapter() {
+ if (countryLabelSorted.isEmpty()) {
+ countryLabelSorted.addAll(countryLabelMapNameCode.keySet());
+ Collections.sort(countryLabelSorted);
+ }
+
+ ArrayAdapter arrayAdapter =
+ new ArrayAdapter<>(getContext(), R.layout.country_dropdown_item, countryLabelSorted);
+ inputEditText.setAdapter(arrayAdapter);
+ }
+
+ /**
+ * Returns whether the current input is a valid dropdown option. Also updates the input error
+ * accordingly.
+ *
+ * @return boolean whether the current input is a valid dropdown option
+ */
+ public boolean validateInput() {
+ if (!setNameCodeForInput()) {
+ enableInputError();
+ return false;
+ }
+
+ disableInputError();
+ return true;
+ }
+
+ /**
+ * Sets the {@link CountryDropdown#nameCode} to the name code of the current input if that's a
+ * valid dropdown option. Else set's it to an empty String.
+ *
+ * @return boolean whether the current input is a valid dropdown option
+ */
+ private boolean setNameCodeForInput() {
+ String nameCodeForInput = countryLabelMapNameCode.get(getInput());
+ if (nameCodeForInput == null) {
+ nameCode = "";
+ return false;
+ }
+
+ nameCode = nameCodeForInput;
+ return true;
+ }
+
+ /** Shows the error message on the input component. */
+ private void enableInputError() {
+ input.setErrorEnabled(true);
+ input.setError(getResources().getString(R.string.main_activity_country_dropdown_error));
+ }
+
+ /** Hides the error message on the input component. */
+ private void disableInputError() {
+ input.setError(null);
+ input.setErrorEnabled(false);
+ }
+
+ private String getInput() {
+ return inputEditText.getText().toString();
+ }
+
+ /**
+ * Returns the name code of the current input if it's a valid dropdown option, else returns an
+ * empty String.
+ *
+ * @return String name code of the current input if it's a valid dropdown option, else returns an
+ * empty String
+ */
+ public String getNameCodeForInput() {
+ setNameCodeForInput();
+ return nameCode;
+ }
+
+ /**
+ * Sets the label of the country with the name code param {@code nameCode} on the input if it's
+ * valid. Else the input is not changed.
+ *
+ * @param nameCode String in format of a name code (e.g. {@code CH})
+ */
+ public void setInputForNameCode(String nameCode) {
+ String countryLabel = getCountryLabelForNameCode(nameCode);
+ if (!countryLabelSorted.contains(countryLabel)) {
+ return;
+ }
+
+ inputEditText.setText(countryLabel);
+ validateInput();
+ }
+
+ @Override
+ public void setEnabled(boolean enabled) {
+ input.setEnabled(enabled);
+ }
+}
diff --git a/java/demoapp/app/src/main/java/com/google/phonenumbers/demoapp/main/MainActivity.java b/java/demoapp/app/src/main/java/com/google/phonenumbers/demoapp/main/MainActivity.java
new file mode 100644
index 0000000000..5df73e3bc8
--- /dev/null
+++ b/java/demoapp/app/src/main/java/com/google/phonenumbers/demoapp/main/MainActivity.java
@@ -0,0 +1,226 @@
+package com.google.phonenumbers.demoapp.main;
+
+import android.content.Context;
+import android.content.Intent;
+import android.content.pm.PackageManager;
+import android.os.Bundle;
+import android.telephony.TelephonyManager;
+import android.view.View;
+import android.widget.Button;
+import android.widget.CheckBox;
+import android.widget.TextView;
+import androidx.annotation.NonNull;
+import androidx.appcompat.app.ActionBar;
+import androidx.appcompat.app.AppCompatActivity;
+import com.google.android.material.snackbar.Snackbar;
+import com.google.phonenumbers.demoapp.R;
+import com.google.phonenumbers.demoapp.contacts.ContactsPermissionManagement;
+import com.google.phonenumbers.demoapp.contacts.ContactsRead;
+import com.google.phonenumbers.demoapp.phonenumbers.PhoneNumberFormatting;
+import com.google.phonenumbers.demoapp.phonenumbers.PhoneNumberInApp;
+import com.google.phonenumbers.demoapp.result.ResultActivity;
+import java.util.ArrayList;
+
+/** Used to handle and process interactions from/with the main page UI of the app. */
+public class MainActivity extends AppCompatActivity {
+
+ private CountryDropdown countryDropdown;
+ private Button btnCountryDropdownReset;
+ private CheckBox cbIgnoreWhitespace;
+ private TextView tvError;
+ private Button btnError;
+ private Button btnStart;
+
+ @Override
+ protected void onCreate(Bundle savedInstanceState) {
+ super.onCreate(savedInstanceState);
+ setContentView(R.layout.activity_main);
+
+ ActionBar actionBar = getSupportActionBar();
+ if (actionBar != null) {
+ actionBar.setTitle(R.string.app_name_long);
+ }
+
+ countryDropdown = findViewById(R.id.country_dropdown);
+ btnCountryDropdownReset = findViewById(R.id.btn_country_dropdown_reset);
+ cbIgnoreWhitespace = findViewById(R.id.cb_ignore_whitespace);
+ tvError = findViewById(R.id.tv_error);
+ btnError = findViewById(R.id.btn_error);
+ btnStart = findViewById(R.id.btn_start);
+
+ btnCountryDropdownReset.setOnClickListener(v -> setSimCountryOnCountryDropdown());
+ btnStart.setOnClickListener(v -> btnStartClicked());
+ }
+
+ @Override
+ protected void onStart() {
+ super.onStart();
+ // Reset all UI elements to default state
+ updateUiState(UiState.SELECT_COUNTRY_CODE);
+ setSimCountryOnCountryDropdown();
+ cbIgnoreWhitespace.setChecked(true);
+ }
+
+ @Override
+ public void onRequestPermissionsResult(
+ int requestCode, @NonNull String[] permissions, @NonNull int[] grantResults) {
+ super.onRequestPermissionsResult(requestCode, permissions, grantResults);
+
+ // Return of the permission result is not about the requested contacts permission
+ if (requestCode != ContactsPermissionManagement.CONTACTS_PERMISSION_REQUEST_CODE) {
+ return;
+ }
+
+ if (grantResults.length == 2
+ && grantResults[0] == PackageManager.PERMISSION_GRANTED
+ && grantResults[1] == PackageManager.PERMISSION_GRANTED) {
+ updateUiState(UiState.PROCESSING);
+ startProcess();
+ } else {
+ ContactsPermissionManagement.addOneToNumberOfDenials(this);
+ switch (ContactsPermissionManagement.getState(this)) {
+ // NEED_REQUEST is specifically to handle the case where the user dismisses the first
+ // permission dialog shown since the app's installation.
+ case NEEDS_REQUEST:
+ case SHOW_RATIONALE:
+ updateUiState(UiState.PERMISSION_ERROR_GRANT_IN_APP);
+ break;
+ case NEEDS_GRANT_IN_SETTINGS:
+ default:
+ updateUiState(UiState.PERMISSION_ERROR_GRANT_IN_SETTINGS);
+ break;
+ }
+ }
+ }
+
+ /**
+ * Updates the UI to represent the param {@code uiState}.
+ *
+ * @param uiState State the UI should be changed to
+ */
+ private void updateUiState(UiState uiState) {
+ // Specifically: countryDropdown, btnCountryDropdownReset, cbIgnoreWhitespace, and btnStart
+ boolean mainInteractionsEnabled = false;
+ // Specifically: tvError, and btnError
+ boolean showError = false;
+
+ switch (uiState) {
+ case SELECT_COUNTRY_CODE:
+ default:
+ mainInteractionsEnabled = true;
+ btnStart.setText(getText(R.string.main_activity_start_text_default));
+ break;
+ case PROCESSING:
+ btnStart.setText(getText(R.string.main_activity_start_text_processing));
+ break;
+ case PERMISSION_ERROR_GRANT_IN_APP:
+ showError = true;
+ tvError.setText(getText(R.string.main_activity_error_text_grant_in_app));
+ btnError.setText(getText(R.string.main_activity_error_cta_grant_in_app));
+ btnError.setOnClickListener(v -> ContactsPermissionManagement.request(this));
+ btnStart.setText(getText(R.string.main_activity_start_text_processing));
+ break;
+ case PERMISSION_ERROR_GRANT_IN_SETTINGS:
+ showError = true;
+ tvError.setText(getText(R.string.main_activity_error_text_grant_in_settings));
+ btnError.setText(getText(R.string.main_activity_error_cta_grant_in_settings));
+ btnError.setOnClickListener(v -> ContactsPermissionManagement.openSystemSettings(this));
+ btnStart.setText(getText(R.string.main_activity_start_text_default));
+ break;
+ }
+
+ countryDropdown.setEnabled(mainInteractionsEnabled);
+ btnCountryDropdownReset.setEnabled(mainInteractionsEnabled);
+ cbIgnoreWhitespace.setEnabled(mainInteractionsEnabled);
+ tvError.setVisibility(showError ? View.VISIBLE : View.INVISIBLE);
+ btnError.setVisibility(showError ? View.VISIBLE : View.INVISIBLE);
+ btnStart.setEnabled(mainInteractionsEnabled);
+ }
+
+ /** Sets the SIM's country as selected item in the country dropdown. */
+ private void setSimCountryOnCountryDropdown() {
+ TelephonyManager telephonyManager =
+ (TelephonyManager) getSystemService(Context.TELEPHONY_SERVICE);
+ countryDropdown.setInputForNameCode(telephonyManager.getSimCountryIso());
+ }
+
+ /**
+ * Called when the start button is clicked. If contacts permissions are granted, starts reading
+ * the contacts. If permissions are not granted, handle that appropriately based on the current
+ * state in the process.
+ */
+ private void btnStartClicked() {
+ updateUiState(UiState.PROCESSING);
+
+ if (!countryDropdown.validateInput()) {
+ updateUiState(UiState.SELECT_COUNTRY_CODE);
+ return;
+ }
+
+ switch (ContactsPermissionManagement.getState(this)) {
+ case ALREADY_GRANTED:
+ startProcess();
+ break;
+ case NEEDS_REQUEST:
+ ContactsPermissionManagement.request(this);
+ break;
+ case SHOW_RATIONALE:
+ updateUiState(UiState.PERMISSION_ERROR_GRANT_IN_APP);
+ break;
+ case NEEDS_GRANT_IN_SETTINGS:
+ default:
+ updateUiState(UiState.PERMISSION_ERROR_GRANT_IN_SETTINGS);
+ break;
+ }
+ }
+
+ /**
+ * Starts the process of reading the contacts, formatting the numbers and starting a {@link
+ * ResultActivity} to show the results.
+ */
+ private void startProcess() {
+ ArrayList phoneNumbersSorted = ContactsRead.getAllPhoneNumbersSorted(this);
+
+ if (phoneNumbersSorted.isEmpty()) {
+ showNoContactsExistSnackbar();
+ updateUiState(UiState.SELECT_COUNTRY_CODE);
+ return;
+ }
+
+ // Format each phone number.
+ for (PhoneNumberInApp phoneNumber : phoneNumbersSorted) {
+ PhoneNumberFormatting.formatPhoneNumberInApp(
+ phoneNumber, countryDropdown.getNameCodeForInput(), cbIgnoreWhitespace.isChecked());
+ }
+
+ // Start new activity to show results.
+ Intent intent = new Intent(this, ResultActivity.class);
+ intent.putExtra(ResultActivity.PHONE_NUMBERS_SORTED_SERIALIZABLE_EXTRA_KEY, phoneNumbersSorted);
+ startActivity(intent);
+ }
+
+ /** Shows a Snackbar informing that no contacts exist. */
+ private void showNoContactsExistSnackbar() {
+ Snackbar.make(
+ countryDropdown, R.string.main_activity_no_contacts_exist_text, Snackbar.LENGTH_LONG)
+ .show();
+ }
+
+ /** Represents the different states the UI of this activity can become. */
+ enum UiState {
+ /** The user should select a country from the dropdown. */
+ SELECT_COUNTRY_CODE,
+ /** Used when loading or processing. The UI is disabled for the user during this time. */
+ PROCESSING,
+ /**
+ * Shows a text explaining that the app needs contacts permission to work, and a button to grant
+ * the permission directly in the app.
+ */
+ PERMISSION_ERROR_GRANT_IN_APP,
+ /**
+ * Shows a text explaining that the app does not have contacts permission, and a button to go to
+ * the system settings to grant the permission.
+ */
+ PERMISSION_ERROR_GRANT_IN_SETTINGS
+ }
+}
diff --git a/java/demoapp/app/src/main/java/com/google/phonenumbers/demoapp/phonenumbers/PhoneNumberFormatting.java b/java/demoapp/app/src/main/java/com/google/phonenumbers/demoapp/phonenumbers/PhoneNumberFormatting.java
new file mode 100644
index 0000000000..a2198c1409
--- /dev/null
+++ b/java/demoapp/app/src/main/java/com/google/phonenumbers/demoapp/phonenumbers/PhoneNumberFormatting.java
@@ -0,0 +1,74 @@
+package com.google.phonenumbers.demoapp.phonenumbers;
+
+import com.google.i18n.phonenumbers.NumberParseException;
+import com.google.i18n.phonenumbers.PhoneNumberUtil;
+import com.google.i18n.phonenumbers.PhoneNumberUtil.PhoneNumberFormat;
+import com.google.i18n.phonenumbers.Phonenumber.PhoneNumber;
+import com.google.i18n.phonenumbers.ShortNumberInfo;
+import com.google.phonenumbers.demoapp.phonenumbers.PhoneNumberInApp.FormattingState;
+
+/**
+ * Handles everything related to the formatting {@link PhoneNumberInApp}s to E.164 format (e.g.
+ * {@code +41446681800}) using LibPhoneNumber ({@link PhoneNumberUtil}).
+ */
+public class PhoneNumberFormatting {
+
+ private static final PhoneNumberUtil phoneNumberUtil = PhoneNumberUtil.getInstance();
+ private static final ShortNumberInfo shortNumberInfo = ShortNumberInfo.getInstance();
+
+ private PhoneNumberFormatting() {}
+
+ /**
+ * Attempts to format the param {@code phoneNumberInApp} in E.164 format (e.g. {@code
+ * +41446681800}) using the country from param {@code nameCodeToUse} (e.g. {@code CH}).
+ *
+ * @param phoneNumberInApp PhoneNumberInApp to format to E.164 format
+ * @param nameCodeToUse String in format of a name code (e.g. {@code CH})
+ * @param ignoreWhitespace boolean whether a phone number should be treated as {@link
+ * FormattingState#NUMBER_IS_ALREADY_IN_E164} instead of suggesting to remove whitespace if
+ * that whitespace is the only difference
+ */
+ public static void formatPhoneNumberInApp(
+ PhoneNumberInApp phoneNumberInApp, String nameCodeToUse, boolean ignoreWhitespace) {
+ PhoneNumber originalPhoneNumberParsed;
+
+ // Check PARSING_ERROR
+ try {
+ originalPhoneNumberParsed =
+ phoneNumberUtil.parse(phoneNumberInApp.getOriginalPhoneNumber(), nameCodeToUse);
+ } catch (NumberParseException e) {
+ phoneNumberInApp.setFormattingState(FormattingState.PARSING_ERROR);
+ return;
+ }
+
+ // Check NUMBER_IS_SHORT_NUMBER
+ if (shortNumberInfo.isValidShortNumber(originalPhoneNumberParsed)) {
+ phoneNumberInApp.setFormattingState(FormattingState.NUMBER_IS_SHORT_NUMBER);
+ return;
+ }
+
+ // Check NUMBER_IS_NOT_VALID
+ if (!phoneNumberUtil.isValidNumber(originalPhoneNumberParsed)) {
+ phoneNumberInApp.setFormattingState(FormattingState.NUMBER_IS_NOT_VALID);
+ return;
+ }
+
+ String formattedPhoneNumber =
+ phoneNumberUtil.format(originalPhoneNumberParsed, PhoneNumberFormat.E164);
+
+ // Check NUMBER_IS_ALREADY_IN_E164
+ if (ignoreWhitespace
+ ? phoneNumberInApp
+ .getOriginalPhoneNumber()
+ .replaceAll("\\s+", "")
+ .equals(formattedPhoneNumber)
+ : phoneNumberInApp.getOriginalPhoneNumber().equals(formattedPhoneNumber)) {
+ phoneNumberInApp.setFormattingState(FormattingState.NUMBER_IS_ALREADY_IN_E164);
+ return;
+ }
+
+ phoneNumberInApp.setFormattedPhoneNumber(formattedPhoneNumber);
+ phoneNumberInApp.setFormattingState(FormattingState.COMPLETED);
+ phoneNumberInApp.setShouldContactBeUpdated(true);
+ }
+}
diff --git a/java/demoapp/app/src/main/java/com/google/phonenumbers/demoapp/phonenumbers/PhoneNumberInApp.java b/java/demoapp/app/src/main/java/com/google/phonenumbers/demoapp/phonenumbers/PhoneNumberInApp.java
new file mode 100644
index 0000000000..6e35d982f1
--- /dev/null
+++ b/java/demoapp/app/src/main/java/com/google/phonenumbers/demoapp/phonenumbers/PhoneNumberInApp.java
@@ -0,0 +1,96 @@
+package com.google.phonenumbers.demoapp.phonenumbers;
+
+import java.io.Serializable;
+
+/**
+ * Represents a phone number and the conversion of it in the app (between reading from and writing
+ * to contacts).
+ */
+public class PhoneNumberInApp implements Serializable, Comparable {
+
+ /** ID to identify the phone number in the device's contacts. */
+ private final String id;
+ /** Display name of the contact the phone number belongs to. */
+ private final String contactName;
+
+ /** Phone number as originally in contacts. */
+ private final String originalPhoneNumber;
+ /**
+ * The in E.164 formatted {@link PhoneNumberInApp#originalPhoneNumber} (e.g. {@code +41446681800})
+ * if formattable, else {@code null}.
+ */
+ private String formattedPhoneNumber = null;
+
+ private FormattingState formattingState = FormattingState.PENDING;
+
+ /**
+ * Equal to the value of the checkbox in the UI. Only if {@code true} the phone number should be
+ * updated in the contacts.
+ */
+ private boolean shouldContactBeUpdated = false;
+
+ public PhoneNumberInApp(String id, String contactName, String originalPhoneNumber) {
+ this.id = id;
+ this.contactName = contactName;
+ this.originalPhoneNumber = originalPhoneNumber;
+ }
+
+ public String getId() {
+ return id;
+ }
+
+ public String getContactName() {
+ return contactName;
+ }
+
+ public String getOriginalPhoneNumber() {
+ return originalPhoneNumber;
+ }
+
+ public String getFormattedPhoneNumber() {
+ return formattedPhoneNumber;
+ }
+
+ public void setFormattedPhoneNumber(String formattedPhoneNumber) {
+ this.formattedPhoneNumber = formattedPhoneNumber;
+ }
+
+ public FormattingState getFormattingState() {
+ return formattingState;
+ }
+
+ public void setFormattingState(FormattingState formattingState) {
+ this.formattingState = formattingState;
+ }
+
+ public boolean shouldContactBeUpdated() {
+ return shouldContactBeUpdated;
+ }
+
+ public void setShouldContactBeUpdated(boolean shouldContactBeUpdated) {
+ this.shouldContactBeUpdated = shouldContactBeUpdated;
+ }
+
+ @Override
+ public int compareTo(PhoneNumberInApp o) {
+ return getContactName().compareTo(o.getContactName());
+ }
+
+ /**
+ * Represents the state the formatting of {@link PhoneNumberInApp#originalPhoneNumber} can be at.
+ */
+ public enum FormattingState {
+ /** Used before the formatting is tried/done. */
+ PENDING,
+ /** Formatting completed to {@link PhoneNumberInApp#formattedPhoneNumber} without errors. */
+ COMPLETED,
+ /** Error while parsing the {@link PhoneNumberInApp#originalPhoneNumber}. */
+ PARSING_ERROR,
+ /** {@link PhoneNumberInApp#originalPhoneNumber} is a short number. */
+ NUMBER_IS_SHORT_NUMBER,
+ /** {@link PhoneNumberInApp#originalPhoneNumber} is not a valid number. */
+ NUMBER_IS_NOT_VALID,
+ /** {@link PhoneNumberInApp#originalPhoneNumber} is already in E.164 format. */
+ NUMBER_IS_ALREADY_IN_E164
+ }
+}
diff --git a/java/demoapp/app/src/main/java/com/google/phonenumbers/demoapp/result/FormattableFragment.java b/java/demoapp/app/src/main/java/com/google/phonenumbers/demoapp/result/FormattableFragment.java
new file mode 100644
index 0000000000..f73fc8f3af
--- /dev/null
+++ b/java/demoapp/app/src/main/java/com/google/phonenumbers/demoapp/result/FormattableFragment.java
@@ -0,0 +1,161 @@
+package com.google.phonenumbers.demoapp.result;
+
+import android.os.Bundle;
+import android.view.LayoutInflater;
+import android.view.View;
+import android.view.ViewGroup;
+import android.widget.Button;
+import androidx.fragment.app.Fragment;
+import androidx.recyclerview.widget.LinearLayoutManager;
+import androidx.recyclerview.widget.RecyclerView;
+import com.google.android.material.snackbar.Snackbar;
+import com.google.phonenumbers.demoapp.R;
+import com.google.phonenumbers.demoapp.contacts.ContactsWrite;
+import com.google.phonenumbers.demoapp.phonenumbers.PhoneNumberInApp;
+import java.util.ArrayList;
+
+/**
+ * Used to handle and process interactions from/with the "Formattable" results section in the result
+ * page UI of the app.
+ */
+public class FormattableFragment extends Fragment {
+
+ /** The fragment root view. */
+ private View root;
+ /** The RecyclerView containing the list. */
+ private RecyclerView recyclerView;
+
+ private Button btnUpdateSelected;
+
+ /** The sorted phone numbers the list currently contains. */
+ private ArrayList phoneNumbers;
+
+ public FormattableFragment(ArrayList phoneNumbers) {
+ this.phoneNumbers = phoneNumbers;
+ }
+
+ @Override
+ public View onCreateView(
+ LayoutInflater inflater, ViewGroup container, Bundle savedInstanceState) {
+ root = inflater.inflate(R.layout.fragment_formattable, container, false);
+ recyclerView = root.findViewById(R.id.recycler_view);
+ recyclerView.setLayoutManager(new LinearLayoutManager(root.getContext()));
+
+ btnUpdateSelected = root.findViewById(R.id.btn_update_selected);
+ btnUpdateSelected.setOnClickListener(v -> btnUpdateSelectedClicked());
+
+ reloadList();
+ return root;
+ }
+
+ /**
+ * Attempts to update the selected contacts and shows success or error based on the outcome.
+ * Called when the update selected button is clicked.
+ */
+ private void btnUpdateSelectedClicked() {
+ updateUiState(UiState.PROCESSING);
+
+ // Get the most up to date list of phone numbers from the RecyclerView adapter.
+ if (recyclerView.getAdapter() == null) {
+ showErrorSnackbar();
+ updateUiState(UiState.SELECT_PHONE_NUMBERS);
+ return;
+ }
+ phoneNumbers = ((FormattableRvAdapter) recyclerView.getAdapter()).getAllPhoneNumbers();
+
+ // Create a sublist with all phone numbers that have the checkbox checked.
+ ArrayList phoneNumbersToUpdate = new ArrayList<>();
+ for (PhoneNumberInApp phoneNumber : phoneNumbers) {
+ if (phoneNumber.shouldContactBeUpdated()) {
+ phoneNumbersToUpdate.add(phoneNumber);
+ }
+ }
+
+ if (phoneNumbersToUpdate.isEmpty()) {
+ showNoNumbersSelectedSnackbar();
+ updateUiState(UiState.SELECT_PHONE_NUMBERS);
+ return;
+ }
+
+ boolean errorWhileUpdatingPhoneNumbers =
+ !ContactsWrite.updatePhoneNumbers(phoneNumbersToUpdate, root.getContext());
+ if (errorWhileUpdatingPhoneNumbers) {
+ showErrorSnackbar();
+ updateUiState(UiState.SELECT_PHONE_NUMBERS);
+ } else {
+ showContactsWriteSuccessSnackbar();
+ phoneNumbers.removeAll(phoneNumbersToUpdate);
+ reloadList();
+ }
+ }
+
+ /** Shows a Snackbar informing that no numbers are selected. */
+ private void showNoNumbersSelectedSnackbar() {
+ Snackbar.make(root, R.string.formattable_no_numbers_selected_text, Snackbar.LENGTH_LONG).show();
+ }
+
+ /** Shows a Snackbar informing that the selected contacts were successfully written. */
+ private void showContactsWriteSuccessSnackbar() {
+ Snackbar.make(root, R.string.formattable_contacts_write_success_text, Snackbar.LENGTH_LONG)
+ .show();
+ }
+
+ /** Shows a Snackbar informing that there was an error (and the user should try again). */
+ private void showErrorSnackbar() {
+ Snackbar.make(root, R.string.formattable_error_text, Snackbar.LENGTH_LONG).show();
+ }
+
+ /**
+ * Reloads the UI so the list contains the phone numbers currently in {@link
+ * FormattableFragment#phoneNumbers}.
+ */
+ private void reloadList() {
+ FormattableRvAdapter adapter = new FormattableRvAdapter(phoneNumbers, root.getContext());
+ recyclerView.setAdapter(adapter);
+ updateUiState(
+ phoneNumbers.isEmpty() ? UiState.NO_PHONE_NUMBERS_IN_LIST : UiState.SELECT_PHONE_NUMBERS);
+ }
+
+ /**
+ * Updates the UI to represent the param {@code uiState}.
+ *
+ * @param uiState State the UI should be changed to
+ */
+ private void updateUiState(UiState uiState) {
+ // Specifically: btnUpdateSelected, and all CheckBoxes (of the list items)
+ boolean mainInteractionsEnabled = false;
+
+ switch (uiState) {
+ case SELECT_PHONE_NUMBERS:
+ default:
+ mainInteractionsEnabled = true;
+ btnUpdateSelected.setText(R.string.formattable_update_selected_text_default);
+ break;
+ case PROCESSING:
+ btnUpdateSelected.setText(R.string.formattable_update_selected_text_processing);
+ break;
+ case NO_PHONE_NUMBERS_IN_LIST:
+ btnUpdateSelected.setText(R.string.formattable_update_selected_text_default);
+ break;
+ }
+
+ btnUpdateSelected.setEnabled(mainInteractionsEnabled);
+
+ if (recyclerView.getAdapter() != null) {
+ ((FormattableRvAdapter) recyclerView.getAdapter()).setAllEnabled(mainInteractionsEnabled);
+ }
+ }
+
+ /** Represents the different states the UI of this fragment can become. */
+ enum UiState {
+ /** The user should select the phone numbers to update. */
+ SELECT_PHONE_NUMBERS,
+ /** Used when loading or processing. The UI is disabled for the user during this time. */
+ PROCESSING,
+ /**
+ * There are no phone number sin the list (the list is empty). Therefore the update selected
+ * button is disabled.
+ */
+ NO_PHONE_NUMBERS_IN_LIST
+ }
+}
diff --git a/java/demoapp/app/src/main/java/com/google/phonenumbers/demoapp/result/FormattableRvAdapter.java b/java/demoapp/app/src/main/java/com/google/phonenumbers/demoapp/result/FormattableRvAdapter.java
new file mode 100644
index 0000000000..a0edd107a4
--- /dev/null
+++ b/java/demoapp/app/src/main/java/com/google/phonenumbers/demoapp/result/FormattableRvAdapter.java
@@ -0,0 +1,157 @@
+package com.google.phonenumbers.demoapp.result;
+
+import android.content.Context;
+import android.view.LayoutInflater;
+import android.view.View;
+import android.view.ViewGroup;
+import android.widget.CheckBox;
+import android.widget.TextView;
+import androidx.annotation.NonNull;
+import androidx.constraintlayout.widget.ConstraintLayout;
+import androidx.recyclerview.widget.RecyclerView;
+import com.google.phonenumbers.demoapp.R;
+import com.google.phonenumbers.demoapp.phonenumbers.PhoneNumberInApp;
+import java.util.ArrayList;
+
+/** Adapter for the {@link RecyclerView} used in {@link FormattableFragment}. */
+public class FormattableRvAdapter extends RecyclerView.Adapter {
+
+ private final LayoutInflater layoutInflater;
+
+ /** List of the original version of {@link PhoneNumberInApp}s at the time of object creation. */
+ private final ArrayList originalPhoneNumbers;
+
+ /** List of all created {@link ViewHolder}s. */
+ private final ArrayList viewHolders = new ArrayList<>();
+
+ public FormattableRvAdapter(ArrayList phoneNumbers, Context context) {
+ this.originalPhoneNumbers = phoneNumbers;
+ this.layoutInflater = LayoutInflater.from(context);
+ }
+
+ @NonNull
+ @Override
+ public ViewHolder onCreateViewHolder(@NonNull ViewGroup parent, int viewType) {
+ View view = layoutInflater.inflate(R.layout.formattable_list_item, parent, false);
+ ViewHolder viewHolder = new ViewHolder(view);
+ viewHolders.add(viewHolder);
+ return viewHolder;
+ }
+
+ @Override
+ public void onViewRecycled(@NonNull ViewHolder holder) {
+ super.onViewRecycled(holder);
+ viewHolders.remove(holder);
+ }
+
+ @Override
+ public void onBindViewHolder(@NonNull ViewHolder viewHolder, int position) {
+ if (position >= 0 && position < getItemCount()) {
+ viewHolder.setFromPhoneNumberInAppRepresentation(originalPhoneNumbers.get(position));
+ }
+ }
+
+ @Override
+ public int getItemCount() {
+ return originalPhoneNumbers.size();
+ }
+
+ /**
+ * Sets the enabled state for the checkbox of all list items.
+ *
+ * @param enabled boolean enable state to set
+ */
+ public void setAllEnabled(boolean enabled) {
+ for (ViewHolder viewHolder : viewHolders) {
+ viewHolder.setEnabled(enabled);
+ }
+ }
+
+ /**
+ * Returns a list of all list items as {@link PhoneNumberInApp}s in the current state of the UI.
+ *
+ * @return ArrayList of all list items as {@link PhoneNumberInApp}s in the current state of the UI
+ */
+ public ArrayList getAllPhoneNumbers() {
+ ArrayList phoneNumbers = new ArrayList<>();
+ for (ViewHolder viewHolder : viewHolders) {
+ phoneNumbers.add(viewHolder.getPhoneNumberInAppRepresentation());
+ }
+ return phoneNumbers;
+ }
+
+ /** {@link RecyclerView.ViewHolder} specifically for a list item of a formattable phone number. */
+ public static class ViewHolder extends RecyclerView.ViewHolder {
+
+ /** Representation of the UI as a {@link PhoneNumberInApp}. */
+ private PhoneNumberInApp phoneNumberInAppRepresentation;
+
+ private final TextView tvContactName;
+ private final TextView tvOriginalPhoneNumber;
+ private final TextView tvArrow;
+ private final TextView tvFormattedPhoneNumber;
+
+ private final CheckBox checkBox;
+
+ public ViewHolder(View view) {
+ super(view);
+ ConstraintLayout clListItem = view.findViewById(R.id.cl_list_item);
+ clListItem.setOnClickListener(v -> toggleChecked());
+
+ tvContactName = view.findViewById(R.id.tv_contact_name);
+ tvOriginalPhoneNumber = view.findViewById(R.id.tv_original_phone_number);
+ tvArrow = view.findViewById(R.id.tv_arrow);
+ tvFormattedPhoneNumber = view.findViewById(R.id.tv_formatted_phone_number);
+ checkBox = view.findViewById(R.id.check_box);
+
+ checkBox.setOnCheckedChangeListener((buttonView, isChecked) -> updateUiToMatchCheckBox());
+ }
+
+ /**
+ * Sets the content of the view to the information of param {@code
+ * phoneNumberInAppRepresentation}.
+ *
+ * @param phoneNumberInAppRepresentation PhoneNumberInApp to set content of the view from
+ */
+ public void setFromPhoneNumberInAppRepresentation(
+ PhoneNumberInApp phoneNumberInAppRepresentation) {
+ this.phoneNumberInAppRepresentation = phoneNumberInAppRepresentation;
+ tvContactName.setText(phoneNumberInAppRepresentation.getContactName());
+ tvOriginalPhoneNumber.setText(phoneNumberInAppRepresentation.getOriginalPhoneNumber());
+ String formattedPhoneNumber = phoneNumberInAppRepresentation.getFormattedPhoneNumber();
+ tvFormattedPhoneNumber.setText(formattedPhoneNumber != null ? formattedPhoneNumber : "");
+ checkBox.setChecked(phoneNumberInAppRepresentation.shouldContactBeUpdated());
+ }
+
+ /** Toggles the checked state of the {@link ViewHolder#checkBox} if it is enabled. */
+ private void toggleChecked() {
+ if (checkBox.isEnabled()) {
+ checkBox.toggle();
+ phoneNumberInAppRepresentation.setShouldContactBeUpdated(checkBox.isChecked());
+ }
+ }
+
+ /**
+ * Update the rest of the UI elements to represent the checked state of {@link
+ * ViewHolder#checkBox} correctly.
+ */
+ private void updateUiToMatchCheckBox() {
+ boolean isChecked = checkBox.isChecked();
+ tvArrow.setEnabled(isChecked);
+ tvFormattedPhoneNumber.setEnabled(isChecked);
+ }
+
+ /**
+ * Sets the enabled state of the {@link ViewHolder#checkBox}.
+ *
+ * @param enabled boolean whether the {@link ViewHolder#checkBox} should be enabled
+ */
+ public void setEnabled(boolean enabled) {
+ checkBox.setEnabled(enabled);
+ }
+
+ public PhoneNumberInApp getPhoneNumberInAppRepresentation() {
+ return phoneNumberInAppRepresentation;
+ }
+ }
+}
diff --git a/java/demoapp/app/src/main/java/com/google/phonenumbers/demoapp/result/NotFormattableFragment.java b/java/demoapp/app/src/main/java/com/google/phonenumbers/demoapp/result/NotFormattableFragment.java
new file mode 100644
index 0000000000..52061472d6
--- /dev/null
+++ b/java/demoapp/app/src/main/java/com/google/phonenumbers/demoapp/result/NotFormattableFragment.java
@@ -0,0 +1,119 @@
+package com.google.phonenumbers.demoapp.result;
+
+import android.os.Bundle;
+import android.view.LayoutInflater;
+import android.view.View;
+import android.view.ViewGroup;
+import androidx.fragment.app.Fragment;
+import androidx.recyclerview.widget.LinearLayoutManager;
+import androidx.recyclerview.widget.RecyclerView;
+import com.google.android.material.chip.Chip;
+import com.google.android.material.snackbar.Snackbar;
+import com.google.phonenumbers.demoapp.R;
+import com.google.phonenumbers.demoapp.phonenumbers.PhoneNumberInApp;
+import com.google.phonenumbers.demoapp.phonenumbers.PhoneNumberInApp.FormattingState;
+import java.util.ArrayList;
+import java.util.Arrays;
+
+/**
+ * Used to handle and process interactions from/with the "Not formattable" results section in the
+ * result page UI of the app.
+ */
+public class NotFormattableFragment extends Fragment {
+
+ /** The fragment root view. */
+ private View root;
+ /** The RecyclerView containing the list. */
+ private RecyclerView recyclerView;
+
+ /**
+ * The sorted phone numbers the list contains (some might not be visible in the UI due to the
+ * {@link NotFormattableFragment#appliedFilters}).
+ */
+ private final ArrayList phoneNumbers;
+
+ /** The filters that are currently applied to the list. */
+ private final ArrayList appliedFilters = new ArrayList<>();
+
+ public NotFormattableFragment(ArrayList phoneNumbers) {
+ this.phoneNumbers = phoneNumbers;
+ }
+
+ @Override
+ public View onCreateView(
+ LayoutInflater inflater, ViewGroup container, Bundle savedInstanceState) {
+ root = inflater.inflate(R.layout.fragment_not_formattable, container, false);
+ recyclerView = root.findViewById(R.id.recycler_view);
+ recyclerView.setLayoutManager(new LinearLayoutManager(root.getContext()));
+
+ Chip chipParsingError = root.findViewById(R.id.chip_parsing_error);
+ connectChipToFormattingState(chipParsingError, FormattingState.PARSING_ERROR);
+ Chip chipShortNumber = root.findViewById(R.id.chip_short_number);
+ connectChipToFormattingState(chipShortNumber, FormattingState.NUMBER_IS_SHORT_NUMBER);
+ Chip chipAlreadyE164 = root.findViewById(R.id.chip_already_e164);
+ connectChipToFormattingState(chipAlreadyE164, FormattingState.NUMBER_IS_ALREADY_IN_E164);
+ Chip chipInvalidNumber = root.findViewById(R.id.chip_invalid_number);
+ connectChipToFormattingState(chipInvalidNumber, FormattingState.NUMBER_IS_NOT_VALID);
+
+ // Add add filters as they are all preselected in the UI
+ appliedFilters.addAll(
+ Arrays.asList(
+ FormattingState.PARSING_ERROR,
+ FormattingState.NUMBER_IS_SHORT_NUMBER,
+ FormattingState.NUMBER_IS_ALREADY_IN_E164,
+ FormattingState.NUMBER_IS_NOT_VALID));
+ // List only needs to be loaded if there are phone numbers.
+ if (!phoneNumbers.isEmpty()) {
+ reloadListWithFilters();
+ }
+ return root;
+ }
+
+ /**
+ * Sets up the param {@code chip} to add/remove the param {@code formattingState} from the {@link
+ * NotFormattableFragment#appliedFilters} list when it is checked/unchecked, and then reloads the
+ * phone number list.
+ *
+ * @param chip Chip of which to handle check/uncheck action
+ * @param formattingState FormattingState the param {@code chip} represents
+ */
+ private void connectChipToFormattingState(Chip chip, FormattingState formattingState) {
+ chip.setOnCheckedChangeListener(
+ (buttonView, isChecked) -> {
+ if (isChecked) {
+ appliedFilters.add(formattingState);
+ } else {
+ appliedFilters.remove(formattingState);
+ }
+ reloadListWithFilters();
+ });
+ }
+
+ /**
+ * Reloads the UI so the list contains the phone numbers matching the currently {@link
+ * NotFormattableFragment#appliedFilters}.
+ */
+ private void reloadListWithFilters() {
+ ArrayList phoneNumbersToShow = new ArrayList<>();
+ for (PhoneNumberInApp phoneNumber : phoneNumbers) {
+ if (appliedFilters.contains(phoneNumber.getFormattingState())) {
+ phoneNumbersToShow.add(phoneNumber);
+ }
+ }
+
+ if (phoneNumbersToShow.isEmpty()) {
+ showNoNumbersMatchFiltersSnackbar();
+ }
+
+ NotFormattableRvAdapter adapter =
+ new NotFormattableRvAdapter(phoneNumbersToShow, root.getContext());
+ recyclerView.setAdapter(adapter);
+ }
+
+ /** Shows a Snackbar informing that no numbers match the selected filters. */
+ private void showNoNumbersMatchFiltersSnackbar() {
+ Snackbar.make(
+ root, R.string.not_formattable_no_numbers_match_filters_text, Snackbar.LENGTH_LONG)
+ .show();
+ }
+}
diff --git a/java/demoapp/app/src/main/java/com/google/phonenumbers/demoapp/result/NotFormattableRvAdapter.java b/java/demoapp/app/src/main/java/com/google/phonenumbers/demoapp/result/NotFormattableRvAdapter.java
new file mode 100644
index 0000000000..3297d0359c
--- /dev/null
+++ b/java/demoapp/app/src/main/java/com/google/phonenumbers/demoapp/result/NotFormattableRvAdapter.java
@@ -0,0 +1,94 @@
+package com.google.phonenumbers.demoapp.result;
+
+import android.content.Context;
+import android.view.LayoutInflater;
+import android.view.View;
+import android.view.ViewGroup;
+import android.widget.TextView;
+import androidx.annotation.NonNull;
+import androidx.recyclerview.widget.RecyclerView;
+import com.google.phonenumbers.demoapp.R;
+import com.google.phonenumbers.demoapp.phonenumbers.PhoneNumberInApp;
+import java.util.ArrayList;
+
+/** Adapter for the {@link RecyclerView} used in {@link NotFormattableFragment}. */
+public class NotFormattableRvAdapter
+ extends RecyclerView.Adapter {
+
+ private final LayoutInflater layoutInflater;
+
+ /** List of the original version of {@link PhoneNumberInApp}s at the time of object creation. */
+ private final ArrayList originalPhoneNumbers;
+
+ public NotFormattableRvAdapter(ArrayList phoneNumbers, Context context) {
+ this.originalPhoneNumbers = phoneNumbers;
+ this.layoutInflater = LayoutInflater.from(context);
+ }
+
+ @NonNull
+ @Override
+ public ViewHolder onCreateViewHolder(@NonNull ViewGroup parent, int viewType) {
+ View view = layoutInflater.inflate(R.layout.not_formattable_list_item, parent, false);
+ return new ViewHolder(view);
+ }
+
+ @Override
+ public void onBindViewHolder(@NonNull ViewHolder viewHolder, int position) {
+ if (position >= 0 && position < getItemCount()) {
+ viewHolder.setFromPhoneNumberInAppRepresentation(originalPhoneNumbers.get(position));
+ }
+ }
+
+ @Override
+ public int getItemCount() {
+ return originalPhoneNumbers.size();
+ }
+
+ /**
+ * {@link RecyclerView.ViewHolder} specifically for a list item of a not formattable phone number.
+ */
+ public static class ViewHolder extends RecyclerView.ViewHolder {
+
+ private final TextView tvContactName;
+ private final TextView tvReason;
+ private final TextView tvOriginalPhoneNumber;
+
+ public ViewHolder(View view) {
+ super(view);
+ tvContactName = view.findViewById(R.id.tv_contact_name);
+ tvReason = view.findViewById(R.id.tv_reason);
+ tvOriginalPhoneNumber = view.findViewById(R.id.tv_original_phone_number);
+ }
+
+ /**
+ * Sets the content of the view to the information of param {@code
+ * phoneNumberInAppRepresentation}.
+ *
+ * @param phoneNumberInAppRepresentation PhoneNumberInApp to set content of the view from
+ */
+ public void setFromPhoneNumberInAppRepresentation(
+ PhoneNumberInApp phoneNumberInAppRepresentation) {
+ tvContactName.setText(phoneNumberInAppRepresentation.getContactName());
+
+ switch (phoneNumberInAppRepresentation.getFormattingState()) {
+ case PARSING_ERROR:
+ tvReason.setText(R.string.not_formattable_parsing_error_text);
+ break;
+ case NUMBER_IS_SHORT_NUMBER:
+ tvReason.setText(R.string.not_formattable_short_number_text);
+ break;
+ case NUMBER_IS_ALREADY_IN_E164:
+ tvReason.setText(R.string.not_formattable_already_e164_text);
+ break;
+ case NUMBER_IS_NOT_VALID:
+ tvReason.setText(R.string.not_formattable_invalid_number_text);
+ break;
+ default:
+ tvReason.setText(R.string.not_formattable_unknown_error_text);
+ break;
+ }
+
+ tvOriginalPhoneNumber.setText(phoneNumberInAppRepresentation.getOriginalPhoneNumber());
+ }
+ }
+}
diff --git a/java/demoapp/app/src/main/java/com/google/phonenumbers/demoapp/result/ResultActivity.java b/java/demoapp/app/src/main/java/com/google/phonenumbers/demoapp/result/ResultActivity.java
new file mode 100644
index 0000000000..86ac974d90
--- /dev/null
+++ b/java/demoapp/app/src/main/java/com/google/phonenumbers/demoapp/result/ResultActivity.java
@@ -0,0 +1,102 @@
+package com.google.phonenumbers.demoapp.result;
+
+import android.os.Bundle;
+import android.view.MenuItem;
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+import androidx.appcompat.app.ActionBar;
+import androidx.appcompat.app.AppCompatActivity;
+import androidx.fragment.app.Fragment;
+import androidx.viewpager2.widget.ViewPager2;
+import com.google.android.material.tabs.TabLayout;
+import com.google.android.material.tabs.TabLayoutMediator;
+import com.google.phonenumbers.demoapp.R;
+import com.google.phonenumbers.demoapp.phonenumbers.PhoneNumberInApp;
+import com.google.phonenumbers.demoapp.phonenumbers.PhoneNumberInApp.FormattingState;
+import java.util.ArrayList;
+
+/** Used to handle and process interactions from/with the result page UI of the app. */
+public class ResultActivity extends AppCompatActivity {
+
+ public static final String PHONE_NUMBERS_SORTED_SERIALIZABLE_EXTRA_KEY =
+ "PHONE_NUMBERS_SORTED_SERIALIZABLE_EXTRA";
+
+ @Override
+ @SuppressWarnings("unchecked")
+ protected void onCreate(@Nullable Bundle savedInstanceState) {
+ super.onCreate(savedInstanceState);
+ setContentView(R.layout.activity_result);
+
+ // Setup ActionBar (title, and home button).
+ ActionBar actionBar = getSupportActionBar();
+ if (actionBar != null) {
+ actionBar.setTitle(R.string.app_name_long);
+ actionBar.setHomeAsUpIndicator(R.drawable.ic_outline_home_30);
+ actionBar.setDisplayHomeAsUpEnabled(true);
+ }
+
+ ArrayList phoneNumbersFormattableSorted = new ArrayList<>();
+ ArrayList phoneNumbersNotFormattableSorted = new ArrayList<>();
+ try {
+ ArrayList phoneNumbersSorted =
+ (ArrayList)
+ getIntent().getSerializableExtra(PHONE_NUMBERS_SORTED_SERIALIZABLE_EXTRA_KEY);
+ // Split phoneNumbersSorted into two separate lists.
+ for (PhoneNumberInApp phoneNumber : phoneNumbersSorted) {
+ if (phoneNumber.getFormattingState() == FormattingState.COMPLETED) {
+ phoneNumbersFormattableSorted.add(phoneNumber);
+ } else if (phoneNumber.getFormattingState() != FormattingState.PENDING) {
+ phoneNumbersNotFormattableSorted.add(phoneNumber);
+ }
+ }
+ } catch (ClassCastException exception) {
+ this.finish();
+ }
+
+ // Create two Fragments with each one of the split lists.
+ FormattableFragment formattableFragment =
+ new FormattableFragment(phoneNumbersFormattableSorted);
+ NotFormattableFragment notFormattableFragment =
+ new NotFormattableFragment(phoneNumbersNotFormattableSorted);
+ setUpTapLayout(formattableFragment, notFormattableFragment);
+ }
+
+ /**
+ * Sets up the {@link TabLayout} with the two param fragments.
+ *
+ * @param formattableFragment FormattableFragment for first tap
+ * @param notFormattableFragment NotFormattableFragment for second tab
+ */
+ private void setUpTapLayout(
+ FormattableFragment formattableFragment, NotFormattableFragment notFormattableFragment) {
+ // The Fragments for the taps in correct order.
+ ArrayList fragments = new ArrayList<>();
+ // The titles for the tabs (respectively for the Fragment at the same position in fragments).
+ ArrayList fragmentTitles = new ArrayList<>();
+ fragments.add(formattableFragment);
+ fragmentTitles.add(getString(R.string.formattable_formattable_text));
+ fragments.add(notFormattableFragment);
+ fragmentTitles.add(getString(R.string.not_formattable_not_formattable_text));
+
+ ResultVpAdapter vpAdapter =
+ new ResultVpAdapter(getSupportFragmentManager(), getLifecycle(), fragments, fragmentTitles);
+ ViewPager2 viewPager = findViewById(R.id.view_pager);
+ viewPager.setAdapter(vpAdapter);
+
+ new TabLayoutMediator(
+ findViewById(R.id.tab_layout),
+ viewPager,
+ (tab, position) -> tab.setText(vpAdapter.getTitle(position)))
+ .attach();
+ }
+
+ @Override
+ public boolean onOptionsItemSelected(@NonNull MenuItem item) {
+ // If home button (house icon) in the ActionBar
+ if (item.getItemId() == android.R.id.home) {
+ this.finish();
+ return true;
+ }
+ return super.onOptionsItemSelected(item);
+ }
+}
diff --git a/java/demoapp/app/src/main/java/com/google/phonenumbers/demoapp/result/ResultVpAdapter.java b/java/demoapp/app/src/main/java/com/google/phonenumbers/demoapp/result/ResultVpAdapter.java
new file mode 100644
index 0000000000..812cec728f
--- /dev/null
+++ b/java/demoapp/app/src/main/java/com/google/phonenumbers/demoapp/result/ResultVpAdapter.java
@@ -0,0 +1,72 @@
+package com.google.phonenumbers.demoapp.result;
+
+import androidx.annotation.NonNull;
+import androidx.fragment.app.Fragment;
+import androidx.fragment.app.FragmentManager;
+import androidx.lifecycle.Lifecycle;
+import androidx.viewpager2.adapter.FragmentStateAdapter;
+import androidx.viewpager2.widget.ViewPager2;
+import java.util.ArrayList;
+
+/** Adapter for the {@link androidx.viewpager2.widget.ViewPager2} used in {@link ResultActivity}. */
+class ResultVpAdapter extends FragmentStateAdapter {
+
+ private final ArrayList fragments;
+ private final ArrayList titles;
+
+ /**
+ * Constructor to set predefined Fragments and their titles.
+ *
+ * @param fragmentManager of {@link ViewPager2}'s host
+ * @param lifecycle of {@link ViewPager2}'s host
+ * @param fragments ArrayList of predefined Fragments (in correct order)
+ * @param titles ArrayList of titles of the predefined Fragments in param {@code fragments}
+ * (respectively for the Fragment at the same position in param {@code fragments}
+ */
+ public ResultVpAdapter(
+ @NonNull FragmentManager fragmentManager,
+ @NonNull Lifecycle lifecycle,
+ ArrayList fragments,
+ ArrayList titles) {
+ super(fragmentManager, lifecycle);
+ this.fragments = fragments;
+ this.titles = titles;
+ }
+
+ /**
+ * Returns the predefined Fragment (set with constructor) at position param {@code position}.
+ * Returns a new Fragment if no predefined Fragment exists at position.
+ *
+ * @param position int position of the predefined Fragment
+ * @return Fragment at position param {@code position} or new Fragment if no predefined Fragment
+ * exists at position
+ */
+ @NonNull
+ @Override
+ public Fragment createFragment(int position) {
+ if (position >= 0 && position < getItemCount()) {
+ return fragments.get(position);
+ }
+ return new Fragment();
+ }
+
+ @Override
+ public int getItemCount() {
+ return fragments.size();
+ }
+
+ /**
+ * Returns the predefined title (set with constructor) at position param {@code position}. Returns
+ * an empty String if no predefined Fragment exists at position.
+ *
+ * @param position int position of the predefined title
+ * @return String title at position param {@code position} or empty String if no predefined title
+ * exists at position
+ */
+ public String getTitle(int position) {
+ if (position >= 0 && position < titles.size()) {
+ return titles.get(position);
+ }
+ return "";
+ }
+}
diff --git a/java/demoapp/app/src/main/res/drawable/ic_launcher_background.xml b/java/demoapp/app/src/main/res/drawable/ic_launcher_background.xml
new file mode 100644
index 0000000000..6f91cc7915
--- /dev/null
+++ b/java/demoapp/app/src/main/res/drawable/ic_launcher_background.xml
@@ -0,0 +1,32 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/java/demoapp/app/src/main/res/drawable/ic_launcher_foreground.xml b/java/demoapp/app/src/main/res/drawable/ic_launcher_foreground.xml
new file mode 100644
index 0000000000..d4f2aa4f23
--- /dev/null
+++ b/java/demoapp/app/src/main/res/drawable/ic_launcher_foreground.xml
@@ -0,0 +1,16 @@
+
+
+
+
+
diff --git a/java/demoapp/app/src/main/res/drawable/ic_outline_home_30.xml b/java/demoapp/app/src/main/res/drawable/ic_outline_home_30.xml
new file mode 100644
index 0000000000..5d774e12ac
--- /dev/null
+++ b/java/demoapp/app/src/main/res/drawable/ic_outline_home_30.xml
@@ -0,0 +1,10 @@
+
+
+
diff --git a/java/demoapp/app/src/main/res/layout/activity_main.xml b/java/demoapp/app/src/main/res/layout/activity_main.xml
new file mode 100644
index 0000000000..cd5f8a582e
--- /dev/null
+++ b/java/demoapp/app/src/main/res/layout/activity_main.xml
@@ -0,0 +1,83 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/java/demoapp/app/src/main/res/layout/activity_result.xml b/java/demoapp/app/src/main/res/layout/activity_result.xml
new file mode 100644
index 0000000000..2a01888459
--- /dev/null
+++ b/java/demoapp/app/src/main/res/layout/activity_result.xml
@@ -0,0 +1,26 @@
+
+
+
+
+
+
+
+
diff --git a/java/demoapp/app/src/main/res/layout/country_dropdown.xml b/java/demoapp/app/src/main/res/layout/country_dropdown.xml
new file mode 100644
index 0000000000..a6b5dab1e4
--- /dev/null
+++ b/java/demoapp/app/src/main/res/layout/country_dropdown.xml
@@ -0,0 +1,24 @@
+
+
+
+
+
+
+
+
+
+
diff --git a/java/demoapp/app/src/main/res/layout/country_dropdown_item.xml b/java/demoapp/app/src/main/res/layout/country_dropdown_item.xml
new file mode 100644
index 0000000000..e97b58ea16
--- /dev/null
+++ b/java/demoapp/app/src/main/res/layout/country_dropdown_item.xml
@@ -0,0 +1,9 @@
+
+
diff --git a/java/demoapp/app/src/main/res/layout/formattable_list_item.xml b/java/demoapp/app/src/main/res/layout/formattable_list_item.xml
new file mode 100644
index 0000000000..d5f63f131f
--- /dev/null
+++ b/java/demoapp/app/src/main/res/layout/formattable_list_item.xml
@@ -0,0 +1,66 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/java/demoapp/app/src/main/res/layout/fragment_formattable.xml b/java/demoapp/app/src/main/res/layout/fragment_formattable.xml
new file mode 100644
index 0000000000..82aee3cc82
--- /dev/null
+++ b/java/demoapp/app/src/main/res/layout/fragment_formattable.xml
@@ -0,0 +1,30 @@
+
+
+
+
+
+
+
+
diff --git a/java/demoapp/app/src/main/res/layout/fragment_not_formattable.xml b/java/demoapp/app/src/main/res/layout/fragment_not_formattable.xml
new file mode 100644
index 0000000000..1127986dad
--- /dev/null
+++ b/java/demoapp/app/src/main/res/layout/fragment_not_formattable.xml
@@ -0,0 +1,64 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/java/demoapp/app/src/main/res/layout/not_formattable_list_item.xml b/java/demoapp/app/src/main/res/layout/not_formattable_list_item.xml
new file mode 100644
index 0000000000..c1ad14601d
--- /dev/null
+++ b/java/demoapp/app/src/main/res/layout/not_formattable_list_item.xml
@@ -0,0 +1,57 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/java/demoapp/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml b/java/demoapp/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml
new file mode 100644
index 0000000000..ea737cd487
--- /dev/null
+++ b/java/demoapp/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml
@@ -0,0 +1,5 @@
+
+
+
+
+
diff --git a/java/demoapp/app/src/main/res/values-night/themes.xml b/java/demoapp/app/src/main/res/values-night/themes.xml
new file mode 100644
index 0000000000..f352489eeb
--- /dev/null
+++ b/java/demoapp/app/src/main/res/values-night/themes.xml
@@ -0,0 +1,4 @@
+
+
+
+
diff --git a/java/demoapp/app/src/main/res/values/dimens.xml b/java/demoapp/app/src/main/res/values/dimens.xml
new file mode 100644
index 0000000000..02371b02da
--- /dev/null
+++ b/java/demoapp/app/src/main/res/values/dimens.xml
@@ -0,0 +1,9 @@
+
+
+
+ 15dp
+ 30dp
+
+
+ 300dp
+
diff --git a/java/demoapp/app/src/main/res/values/strings.xml b/java/demoapp/app/src/main/res/values/strings.xml
new file mode 100644
index 0000000000..fbe98697f3
--- /dev/null
+++ b/java/demoapp/app/src/main/res/values/strings.xml
@@ -0,0 +1,48 @@
+
+
+
+ E.164
+ E.164 Formatter
+
+
+ Select the country to use
+ Country
+ Choose dropdown item
+ Reset to SIM country
+ Treat as E.164 if only difference is whitespace
+
+ To use this functionality, the app needs access to Contacts.
+ Allow access
+ The app does not have permission to access Contacts. In Settings, tap Permissions and enable Contacts.
+ Go to Settings
+
+ Start
+ Process started…
+
+ No contacts exist
+
+
+ Formattable
+
+ —>
+
+ Update selected
+ Updating selected…
+
+ No numbers selected
+ Successfully updated selected
+ An error occurred. Please try again
+
+
+ Not formattable
+
+ :
+
+ Parsing error
+ Short number
+ Already E.164
+ Invalid number
+ Unknown error
+
+ No numbers match the selected filters
+
diff --git a/java/demoapp/app/src/main/res/values/themes.xml b/java/demoapp/app/src/main/res/values/themes.xml
new file mode 100644
index 0000000000..c262da82eb
--- /dev/null
+++ b/java/demoapp/app/src/main/res/values/themes.xml
@@ -0,0 +1,4 @@
+
+
+
+
diff --git a/java/demoapp/app/src/test/java/com/google/phonenumbers/demoapp/phonenumbers/PhoneNumberFormattingTest.java b/java/demoapp/app/src/test/java/com/google/phonenumbers/demoapp/phonenumbers/PhoneNumberFormattingTest.java
new file mode 100644
index 0000000000..185e03b4cb
--- /dev/null
+++ b/java/demoapp/app/src/test/java/com/google/phonenumbers/demoapp/phonenumbers/PhoneNumberFormattingTest.java
@@ -0,0 +1,103 @@
+package com.google.phonenumbers.demoapp.phonenumbers;
+
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertFalse;
+import static org.junit.Assert.assertNull;
+import static org.junit.Assert.assertTrue;
+
+import com.google.phonenumbers.demoapp.phonenumbers.PhoneNumberInApp.FormattingState;
+import org.junit.Test;
+
+/** JUnit Tests for class {@link PhoneNumberFormatting}. */
+public class PhoneNumberFormattingTest {
+
+ @Test
+ public void formatPhoneNumberInApp_parsingError() {
+ PhoneNumberInApp phoneNumberInApp = new PhoneNumberInApp("19735", "Izabelle Goodwin", "#");
+
+ PhoneNumberFormatting.formatPhoneNumberInApp(phoneNumberInApp, "CH", false);
+
+ assertNull(phoneNumberInApp.getFormattedPhoneNumber());
+ assertEquals(FormattingState.PARSING_ERROR, phoneNumberInApp.getFormattingState());
+ assertFalse(phoneNumberInApp.shouldContactBeUpdated());
+ }
+
+ @Test
+ public void formatPhoneNumberInApp_numberIsShortNumber() {
+ PhoneNumberInApp phoneNumberInApp = new PhoneNumberInApp("2", "Beatrice Bradley", "144");
+
+ PhoneNumberFormatting.formatPhoneNumberInApp(phoneNumberInApp, "CH", false);
+
+ assertNull(phoneNumberInApp.getFormattedPhoneNumber());
+ assertEquals(FormattingState.NUMBER_IS_SHORT_NUMBER, phoneNumberInApp.getFormattingState());
+ assertFalse(phoneNumberInApp.shouldContactBeUpdated());
+ }
+
+ @Test
+ public void formatPhoneNumberInApp_invalidNumber() {
+ PhoneNumberInApp phoneNumberInApp =
+ new PhoneNumberInApp("1283", "Donte Salinas", "04466818029999");
+
+ PhoneNumberFormatting.formatPhoneNumberInApp(phoneNumberInApp, "CH", false);
+
+ assertNull(phoneNumberInApp.getFormattedPhoneNumber());
+ assertEquals(FormattingState.NUMBER_IS_NOT_VALID, phoneNumberInApp.getFormattingState());
+ assertFalse(phoneNumberInApp.shouldContactBeUpdated());
+ }
+
+ @Test
+ public void formatPhoneNumberInApp_numberIsAlreadyInE164() {
+ PhoneNumberInApp phoneNumberInApp =
+ new PhoneNumberInApp("345", "Kassandra Coffey", "+41446681804");
+
+ PhoneNumberFormatting.formatPhoneNumberInApp(phoneNumberInApp, "CH", false);
+
+ assertNull(phoneNumberInApp.getFormattedPhoneNumber());
+ assertEquals(FormattingState.NUMBER_IS_ALREADY_IN_E164, phoneNumberInApp.getFormattingState());
+ assertFalse(phoneNumberInApp.shouldContactBeUpdated());
+ }
+
+ @Test
+ public void
+ formatPhoneNumberInApp_originalWithWhitespace_ignoreWhitespaceTrue_numberIsAlreadyInE164() {
+ PhoneNumberInApp phoneNumberInApp =
+
+ new PhoneNumberInApp("443221", "Nayeli Martinez", "+41 446 68 18 07");
+ PhoneNumberFormatting.formatPhoneNumberInApp(phoneNumberInApp, "CH", true);
+
+ assertNull(phoneNumberInApp.getFormattedPhoneNumber());
+ assertEquals(FormattingState.NUMBER_IS_ALREADY_IN_E164, phoneNumberInApp.getFormattingState());
+ assertFalse(phoneNumberInApp.shouldContactBeUpdated());
+ }
+
+ @Test
+ public void formatPhoneNumberInApp_originalWithWhitespace_ignoreWhitespaceFalse_completed() {
+ PhoneNumberInApp phoneNumberInApp =
+ new PhoneNumberInApp("22", "Mariyah Johnston", "+41 446 68 18 05");
+
+ PhoneNumberFormatting.formatPhoneNumberInApp(phoneNumberInApp, "CH", false);
+
+ assertEquals("+41446681805", phoneNumberInApp.getFormattedPhoneNumber());
+ assertEquals(FormattingState.COMPLETED, phoneNumberInApp.getFormattingState());
+ assertTrue(phoneNumberInApp.shouldContactBeUpdated());
+ }
+
+ @Test
+ public void formatPhoneNumberInApp_completed() {
+ PhoneNumberInApp phoneNumberInAppCh = new PhoneNumberInApp("45", "Alena Potts", "0446681800");
+ PhoneNumberInApp phoneNumberInAppUs =
+ new PhoneNumberInApp("3829", "Rebecca Haimo", "9495550102");
+
+ PhoneNumberFormatting.formatPhoneNumberInApp(phoneNumberInAppCh, "CH", false);
+ PhoneNumberFormatting.formatPhoneNumberInApp(phoneNumberInAppUs, "US", false);
+
+ String expectedFormattedPhoneNumberCh = "+41446681800";
+ assertEquals(expectedFormattedPhoneNumberCh, phoneNumberInAppCh.getFormattedPhoneNumber());
+ assertEquals(FormattingState.COMPLETED, phoneNumberInAppCh.getFormattingState());
+ assertTrue(phoneNumberInAppCh.shouldContactBeUpdated());
+ String expectedFormattedPhoneNumberUs = "+19495550102";
+ assertEquals(expectedFormattedPhoneNumberUs, phoneNumberInAppUs.getFormattedPhoneNumber());
+ assertEquals(FormattingState.COMPLETED, phoneNumberInAppUs.getFormattingState());
+ assertTrue(phoneNumberInAppUs.shouldContactBeUpdated());
+ }
+}
diff --git a/java/demoapp/app/src/test/java/com/google/phonenumbers/demoapp/phonenumbers/PhoneNumberInAppTest.java b/java/demoapp/app/src/test/java/com/google/phonenumbers/demoapp/phonenumbers/PhoneNumberInAppTest.java
new file mode 100644
index 0000000000..031deb891c
--- /dev/null
+++ b/java/demoapp/app/src/test/java/com/google/phonenumbers/demoapp/phonenumbers/PhoneNumberInAppTest.java
@@ -0,0 +1,70 @@
+package com.google.phonenumbers.demoapp.phonenumbers;
+
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertFalse;
+import static org.junit.Assert.assertNull;
+import static org.junit.Assert.assertTrue;
+
+import com.google.phonenumbers.demoapp.phonenumbers.PhoneNumberInApp.FormattingState;
+import org.junit.Test;
+
+/** JUnit Tests for class {@link PhoneNumberInApp}. */
+public class PhoneNumberInAppTest {
+
+ @Test
+ public void constructor() {
+ String id = "45";
+ String contactName = "Alena Potts";
+ String originalPhoneNumber = "0446681800";
+
+ PhoneNumberInApp phoneNumberInApp = new PhoneNumberInApp(id, contactName, originalPhoneNumber);
+
+ assertEquals(id, phoneNumberInApp.getId());
+ assertEquals(contactName, phoneNumberInApp.getContactName());
+ assertEquals(originalPhoneNumber, phoneNumberInApp.getOriginalPhoneNumber());
+ assertNull(phoneNumberInApp.getFormattedPhoneNumber());
+ assertEquals(PhoneNumberInApp.FormattingState.PENDING, phoneNumberInApp.getFormattingState());
+ assertFalse(phoneNumberInApp.shouldContactBeUpdated());
+ }
+
+ @Test
+ public void setFormattedPhoneNumber() {
+ PhoneNumberInApp phoneNumberInApp = new PhoneNumberInApp("2", "Beatrice Bradley", "0446681801");
+ String formattedPhoneNumber = "+41446681801";
+
+ phoneNumberInApp.setFormattedPhoneNumber(formattedPhoneNumber);
+
+ assertEquals(formattedPhoneNumber, phoneNumberInApp.getFormattedPhoneNumber());
+ }
+
+ @Test
+ public void setFormattingState() {
+ PhoneNumberInApp phoneNumberInApp = new PhoneNumberInApp("1283", "Donte Salinas", "0446681802");
+ FormattingState formattingState = FormattingState.NUMBER_IS_ALREADY_IN_E164;
+
+ phoneNumberInApp.setFormattingState(formattingState);
+
+ assertEquals(formattingState, phoneNumberInApp.getFormattingState());
+ }
+
+ @Test
+ public void setShouldContactBeUpdated() {
+ PhoneNumberInApp phoneNumberInApp =
+ new PhoneNumberInApp("19735", "Izabelle Goodwin", "0446681803");
+
+ phoneNumberInApp.setShouldContactBeUpdated(true);
+
+ assertTrue(phoneNumberInApp.shouldContactBeUpdated());
+ }
+
+ @Test
+ public void compareTo() {
+ PhoneNumberInApp phoneNumberInApp1 =
+ new PhoneNumberInApp("345", "Kassandra Coffey", "0446681804");
+ PhoneNumberInApp phoneNumberInApp2 =
+ new PhoneNumberInApp("22", "Mariyah Johnston", "0446681805");
+
+ assertTrue(phoneNumberInApp1.compareTo(phoneNumberInApp2) < 0);
+ assertTrue(phoneNumberInApp2.compareTo(phoneNumberInApp1) > 0);
+ }
+}
diff --git a/java/demoapp/build.gradle b/java/demoapp/build.gradle
new file mode 100644
index 0000000000..9d4048d46b
--- /dev/null
+++ b/java/demoapp/build.gradle
@@ -0,0 +1,5 @@
+// Top-level build file where you can add configuration options common to all sub-projects/modules.
+plugins {
+ id 'com.android.application' version '7.3.1' apply false
+ id 'com.android.library' version '7.3.1' apply false
+}
diff --git a/java/demoapp/gradle.properties b/java/demoapp/gradle.properties
new file mode 100644
index 0000000000..a03b354896
--- /dev/null
+++ b/java/demoapp/gradle.properties
@@ -0,0 +1,21 @@
+# Project-wide Gradle settings.
+# IDE (e.g. Android Studio) users:
+# Gradle settings configured through the IDE *will override*
+# any settings specified in this file.
+# For more details on how to configure your build environment visit
+# http://www.gradle.org/docs/current/userguide/build_environment.html
+# Specifies the JVM arguments used for the daemon process.
+# The setting is particularly useful for tweaking memory settings.
+org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8
+# When configured, Gradle will run in incubating parallel mode.
+# This option should only be used with decoupled projects. More details, visit
+# http://www.gradle.org/docs/current/userguide/multi_project_builds.html#sec:decoupled_projects
+# org.gradle.parallel=true
+# AndroidX package structure to make it clearer which packages are bundled with the
+# Android operating system, and which are packaged with your app's APK
+# https://developer.android.com/topic/libraries/support-library/androidx-rn
+android.useAndroidX=true
+# Enables namespacing of each library's R class so that its R class includes only the
+# resources declared in the library itself and none from the library's dependencies,
+# thereby reducing the size of the R class for that library
+android.nonTransitiveRClass=true
diff --git a/java/demoapp/settings.gradle b/java/demoapp/settings.gradle
new file mode 100644
index 0000000000..406d3819cd
--- /dev/null
+++ b/java/demoapp/settings.gradle
@@ -0,0 +1,16 @@
+pluginManagement {
+ repositories {
+ gradlePluginPortal()
+ google()
+ mavenCentral()
+ }
+}
+dependencyResolutionManagement {
+ repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
+ repositories {
+ google()
+ mavenCentral()
+ }
+}
+rootProject.name = "E.164 Formatter"
+include ':app'