From 9e09524a414d909eb9af6e435dae63bfbb6a4d4f Mon Sep 17 00:00:00 2001 From: Grant Menke Date: Wed, 13 Sep 2023 10:27:01 -0700 Subject: Update libphonenumber to v8.13.20 Fixes: 299382963 Test: build, atest PhoneNumberUtilsTest, and vogar Change-Id: Ie83300873050e6a7d53412b1a60fff803203c3ef --- README.version | 2 +- carrier/pom.xml | 8 +- .../google/i18n/phonenumbers/carrier/data/216_en | Bin 111 -> 115 bytes .../google/i18n/phonenumbers/carrier/data/250_en | Bin 59 -> 69 bytes .../google/i18n/phonenumbers/carrier/data/46_en | Bin 5896 -> 5902 bytes .../google/i18n/phonenumbers/carrier/data/56_en | Bin 20280 -> 20286 bytes .../google/i18n/phonenumbers/carrier/data/592_en | Bin 247 -> 222 bytes demo/pom.xml | 10 +- demoapp/README.md | 51 +++++ demoapp/app/build.gradle | 45 ++++ demoapp/app/src/main/AndroidManifest.xml | 39 ++++ .../google/phonenumbers/demoapp/MyApplication.java | 16 ++ .../contacts/ContactsPermissionManagement.java | 161 +++++++++++++++ .../demoapp/contacts/ContactsRead.java | 62 ++++++ .../demoapp/contacts/ContactsWrite.java | 59 ++++++ .../phonenumbers/demoapp/main/CountryDropdown.java | 203 ++++++++++++++++++ .../phonenumbers/demoapp/main/MainActivity.java | 226 +++++++++++++++++++++ .../phonenumbers/PhoneNumberFormatting.java | 74 +++++++ .../demoapp/phonenumbers/PhoneNumberInApp.java | 96 +++++++++ .../demoapp/result/FormattableFragment.java | 161 +++++++++++++++ .../demoapp/result/FormattableRvAdapter.java | 157 ++++++++++++++ .../demoapp/result/NotFormattableFragment.java | 119 +++++++++++ .../demoapp/result/NotFormattableRvAdapter.java | 94 +++++++++ .../demoapp/result/ResultActivity.java | 102 ++++++++++ .../demoapp/result/ResultVpAdapter.java | 72 +++++++ .../main/res/drawable/ic_launcher_background.xml | 32 +++ .../main/res/drawable/ic_launcher_foreground.xml | 16 ++ .../src/main/res/drawable/ic_outline_home_30.xml | 10 + demoapp/app/src/main/res/layout/activity_main.xml | 83 ++++++++ .../app/src/main/res/layout/activity_result.xml | 26 +++ .../app/src/main/res/layout/country_dropdown.xml | 24 +++ .../src/main/res/layout/country_dropdown_item.xml | 9 + .../src/main/res/layout/formattable_list_item.xml | 66 ++++++ .../src/main/res/layout/fragment_formattable.xml | 30 +++ .../main/res/layout/fragment_not_formattable.xml | 64 ++++++ .../main/res/layout/not_formattable_list_item.xml | 57 ++++++ .../src/main/res/mipmap-anydpi-v26/ic_launcher.xml | 5 + demoapp/app/src/main/res/values-night/themes.xml | 4 + demoapp/app/src/main/res/values/dimens.xml | 9 + demoapp/app/src/main/res/values/strings.xml | 48 +++++ demoapp/app/src/main/res/values/themes.xml | 4 + .../phonenumbers/PhoneNumberFormattingTest.java | 103 ++++++++++ .../demoapp/phonenumbers/PhoneNumberInAppTest.java | 70 +++++++ demoapp/build.gradle | 5 + demoapp/gradle.properties | 21 ++ demoapp/settings.gradle | 16 ++ geocoder/pom.xml | 8 +- internal/prefixmapper/pom.xml | 6 +- libphonenumber/pom.xml | 4 +- .../phonenumbers/data/PhoneNumberMetadataProto_AR | Bin 8267 -> 8267 bytes .../phonenumbers/data/PhoneNumberMetadataProto_BD | Bin 2120 -> 2123 bytes .../phonenumbers/data/PhoneNumberMetadataProto_GY | Bin 486 -> 502 bytes .../phonenumbers/data/PhoneNumberMetadataProto_IL | Bin 1184 -> 1193 bytes .../phonenumbers/data/PhoneNumberMetadataProto_NZ | Bin 1108 -> 1090 bytes .../phonenumbers/data/PhoneNumberMetadataProto_OM | Bin 541 -> 544 bytes .../phonenumbers/data/PhoneNumberMetadataProto_RW | Bin 512 -> 513 bytes .../phonenumbers/data/PhoneNumberMetadataProto_TN | Bin 463 -> 463 bytes pom.xml | 4 +- .../phonenumbers/data/PhoneNumberMetadataProto_AR | Bin 8267 -> 8267 bytes .../phonenumbers/data/PhoneNumberMetadataProto_BD | Bin 2120 -> 2123 bytes .../phonenumbers/data/PhoneNumberMetadataProto_GY | Bin 486 -> 502 bytes .../phonenumbers/data/PhoneNumberMetadataProto_IL | Bin 1184 -> 1193 bytes .../phonenumbers/data/PhoneNumberMetadataProto_NZ | Bin 1108 -> 1090 bytes .../phonenumbers/data/PhoneNumberMetadataProto_OM | Bin 541 -> 544 bytes .../phonenumbers/data/PhoneNumberMetadataProto_RW | Bin 512 -> 513 bytes .../phonenumbers/data/PhoneNumberMetadataProto_TN | Bin 463 -> 463 bytes 66 files changed, 2460 insertions(+), 21 deletions(-) create mode 100644 demoapp/README.md create mode 100644 demoapp/app/build.gradle create mode 100644 demoapp/app/src/main/AndroidManifest.xml create mode 100644 demoapp/app/src/main/java/com/google/phonenumbers/demoapp/MyApplication.java create mode 100644 demoapp/app/src/main/java/com/google/phonenumbers/demoapp/contacts/ContactsPermissionManagement.java create mode 100644 demoapp/app/src/main/java/com/google/phonenumbers/demoapp/contacts/ContactsRead.java create mode 100644 demoapp/app/src/main/java/com/google/phonenumbers/demoapp/contacts/ContactsWrite.java create mode 100644 demoapp/app/src/main/java/com/google/phonenumbers/demoapp/main/CountryDropdown.java create mode 100644 demoapp/app/src/main/java/com/google/phonenumbers/demoapp/main/MainActivity.java create mode 100644 demoapp/app/src/main/java/com/google/phonenumbers/demoapp/phonenumbers/PhoneNumberFormatting.java create mode 100644 demoapp/app/src/main/java/com/google/phonenumbers/demoapp/phonenumbers/PhoneNumberInApp.java create mode 100644 demoapp/app/src/main/java/com/google/phonenumbers/demoapp/result/FormattableFragment.java create mode 100644 demoapp/app/src/main/java/com/google/phonenumbers/demoapp/result/FormattableRvAdapter.java create mode 100644 demoapp/app/src/main/java/com/google/phonenumbers/demoapp/result/NotFormattableFragment.java create mode 100644 demoapp/app/src/main/java/com/google/phonenumbers/demoapp/result/NotFormattableRvAdapter.java create mode 100644 demoapp/app/src/main/java/com/google/phonenumbers/demoapp/result/ResultActivity.java create mode 100644 demoapp/app/src/main/java/com/google/phonenumbers/demoapp/result/ResultVpAdapter.java create mode 100644 demoapp/app/src/main/res/drawable/ic_launcher_background.xml create mode 100644 demoapp/app/src/main/res/drawable/ic_launcher_foreground.xml create mode 100644 demoapp/app/src/main/res/drawable/ic_outline_home_30.xml create mode 100644 demoapp/app/src/main/res/layout/activity_main.xml create mode 100644 demoapp/app/src/main/res/layout/activity_result.xml create mode 100644 demoapp/app/src/main/res/layout/country_dropdown.xml create mode 100644 demoapp/app/src/main/res/layout/country_dropdown_item.xml create mode 100644 demoapp/app/src/main/res/layout/formattable_list_item.xml create mode 100644 demoapp/app/src/main/res/layout/fragment_formattable.xml create mode 100644 demoapp/app/src/main/res/layout/fragment_not_formattable.xml create mode 100644 demoapp/app/src/main/res/layout/not_formattable_list_item.xml create mode 100644 demoapp/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml create mode 100644 demoapp/app/src/main/res/values-night/themes.xml create mode 100644 demoapp/app/src/main/res/values/dimens.xml create mode 100644 demoapp/app/src/main/res/values/strings.xml create mode 100644 demoapp/app/src/main/res/values/themes.xml create mode 100644 demoapp/app/src/test/java/com/google/phonenumbers/demoapp/phonenumbers/PhoneNumberFormattingTest.java create mode 100644 demoapp/app/src/test/java/com/google/phonenumbers/demoapp/phonenumbers/PhoneNumberInAppTest.java create mode 100644 demoapp/build.gradle create mode 100644 demoapp/gradle.properties create mode 100644 demoapp/settings.gradle diff --git a/README.version b/README.version index 127cfaa1..89d76e67 100644 --- a/README.version +++ b/README.version @@ -1,3 +1,3 @@ URL: https://github.com/googlei18n/libphonenumber/ -Version: 8.13.19 +Version: 8.13.20 BugComponent: 20868 diff --git a/carrier/pom.xml b/carrier/pom.xml index 169e2838..cb198993 100644 --- a/carrier/pom.xml +++ b/carrier/pom.xml @@ -3,14 +3,14 @@ 4.0.0 com.googlecode.libphonenumber carrier - 1.203 + 1.204 jar https://github.com/google/libphonenumber/ com.googlecode.libphonenumber libphonenumber-parent - 8.13.19 + 8.13.20 @@ -79,12 +79,12 @@ com.googlecode.libphonenumber libphonenumber - 8.13.19 + 8.13.20 com.googlecode.libphonenumber prefixmapper - 2.213 + 2.214 diff --git a/carrier/src/com/google/i18n/phonenumbers/carrier/data/216_en b/carrier/src/com/google/i18n/phonenumbers/carrier/data/216_en index aa0e8101..786bf231 100644 Binary files a/carrier/src/com/google/i18n/phonenumbers/carrier/data/216_en and b/carrier/src/com/google/i18n/phonenumbers/carrier/data/216_en differ diff --git a/carrier/src/com/google/i18n/phonenumbers/carrier/data/250_en b/carrier/src/com/google/i18n/phonenumbers/carrier/data/250_en index a2cfbf2c..cc01eb04 100644 Binary files a/carrier/src/com/google/i18n/phonenumbers/carrier/data/250_en and b/carrier/src/com/google/i18n/phonenumbers/carrier/data/250_en differ diff --git a/carrier/src/com/google/i18n/phonenumbers/carrier/data/46_en b/carrier/src/com/google/i18n/phonenumbers/carrier/data/46_en index 6723b21d..91064522 100644 Binary files a/carrier/src/com/google/i18n/phonenumbers/carrier/data/46_en and b/carrier/src/com/google/i18n/phonenumbers/carrier/data/46_en differ diff --git a/carrier/src/com/google/i18n/phonenumbers/carrier/data/56_en b/carrier/src/com/google/i18n/phonenumbers/carrier/data/56_en index a8ff5f47..d5b2f1a3 100644 Binary files a/carrier/src/com/google/i18n/phonenumbers/carrier/data/56_en and b/carrier/src/com/google/i18n/phonenumbers/carrier/data/56_en differ diff --git a/carrier/src/com/google/i18n/phonenumbers/carrier/data/592_en b/carrier/src/com/google/i18n/phonenumbers/carrier/data/592_en index ce6352d1..70e6ce7a 100644 Binary files a/carrier/src/com/google/i18n/phonenumbers/carrier/data/592_en and b/carrier/src/com/google/i18n/phonenumbers/carrier/data/592_en differ diff --git a/demo/pom.xml b/demo/pom.xml index 0a9ba529..98a7727d 100644 --- a/demo/pom.xml +++ b/demo/pom.xml @@ -3,13 +3,13 @@ 4.0.0 com.googlecode.libphonenumber demo - 8.13.19 + 8.13.20 war https://github.com/google/libphonenumber/ com.googlecode.libphonenumber libphonenumber-parent - 8.13.19 + 8.13.20 @@ -68,17 +68,17 @@ com.googlecode.libphonenumber libphonenumber - 8.13.19 + 8.13.20 com.googlecode.libphonenumber geocoder - 2.213 + 2.214 com.googlecode.libphonenumber carrier - 1.203 + 1.204 diff --git a/demoapp/README.md b/demoapp/README.md new file mode 100644 index 00000000..94d4db0c --- /dev/null +++ b/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/demoapp/app/build.gradle b/demoapp/app/build.gradle new file mode 100644 index 00000000..9f5344e2 --- /dev/null +++ b/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/demoapp/app/src/main/AndroidManifest.xml b/demoapp/app/src/main/AndroidManifest.xml new file mode 100644 index 00000000..32b365ef --- /dev/null +++ b/demoapp/app/src/main/AndroidManifest.xml @@ -0,0 +1,39 @@ + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/demoapp/app/src/main/java/com/google/phonenumbers/demoapp/MyApplication.java b/demoapp/app/src/main/java/com/google/phonenumbers/demoapp/MyApplication.java new file mode 100644 index 00000000..f15889f2 --- /dev/null +++ b/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/demoapp/app/src/main/java/com/google/phonenumbers/demoapp/contacts/ContactsPermissionManagement.java b/demoapp/app/src/main/java/com/google/phonenumbers/demoapp/contacts/ContactsPermissionManagement.java new file mode 100644 index 00000000..cc90c080 --- /dev/null +++ b/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/demoapp/app/src/main/java/com/google/phonenumbers/demoapp/contacts/ContactsRead.java b/demoapp/app/src/main/java/com/google/phonenumbers/demoapp/contacts/ContactsRead.java new file mode 100644 index 00000000..068da309 --- /dev/null +++ b/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/demoapp/app/src/main/java/com/google/phonenumbers/demoapp/contacts/ContactsWrite.java b/demoapp/app/src/main/java/com/google/phonenumbers/demoapp/contacts/ContactsWrite.java new file mode 100644 index 00000000..434ba55a --- /dev/null +++ b/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/demoapp/app/src/main/java/com/google/phonenumbers/demoapp/main/CountryDropdown.java b/demoapp/app/src/main/java/com/google/phonenumbers/demoapp/main/CountryDropdown.java new file mode 100644 index 00000000..5b36e3c4 --- /dev/null +++ b/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/demoapp/app/src/main/java/com/google/phonenumbers/demoapp/main/MainActivity.java b/demoapp/app/src/main/java/com/google/phonenumbers/demoapp/main/MainActivity.java new file mode 100644 index 00000000..5df73e3b --- /dev/null +++ b/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/demoapp/app/src/main/java/com/google/phonenumbers/demoapp/phonenumbers/PhoneNumberFormatting.java b/demoapp/app/src/main/java/com/google/phonenumbers/demoapp/phonenumbers/PhoneNumberFormatting.java new file mode 100644 index 00000000..a2198c14 --- /dev/null +++ b/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/demoapp/app/src/main/java/com/google/phonenumbers/demoapp/phonenumbers/PhoneNumberInApp.java b/demoapp/app/src/main/java/com/google/phonenumbers/demoapp/phonenumbers/PhoneNumberInApp.java new file mode 100644 index 00000000..6e35d982 --- /dev/null +++ b/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/demoapp/app/src/main/java/com/google/phonenumbers/demoapp/result/FormattableFragment.java b/demoapp/app/src/main/java/com/google/phonenumbers/demoapp/result/FormattableFragment.java new file mode 100644 index 00000000..f73fc8f3 --- /dev/null +++ b/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/demoapp/app/src/main/java/com/google/phonenumbers/demoapp/result/FormattableRvAdapter.java b/demoapp/app/src/main/java/com/google/phonenumbers/demoapp/result/FormattableRvAdapter.java new file mode 100644 index 00000000..a0edd107 --- /dev/null +++ b/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/demoapp/app/src/main/java/com/google/phonenumbers/demoapp/result/NotFormattableFragment.java b/demoapp/app/src/main/java/com/google/phonenumbers/demoapp/result/NotFormattableFragment.java new file mode 100644 index 00000000..52061472 --- /dev/null +++ b/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/demoapp/app/src/main/java/com/google/phonenumbers/demoapp/result/NotFormattableRvAdapter.java b/demoapp/app/src/main/java/com/google/phonenumbers/demoapp/result/NotFormattableRvAdapter.java new file mode 100644 index 00000000..3297d035 --- /dev/null +++ b/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/demoapp/app/src/main/java/com/google/phonenumbers/demoapp/result/ResultActivity.java b/demoapp/app/src/main/java/com/google/phonenumbers/demoapp/result/ResultActivity.java new file mode 100644 index 00000000..86ac974d --- /dev/null +++ b/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/demoapp/app/src/main/java/com/google/phonenumbers/demoapp/result/ResultVpAdapter.java b/demoapp/app/src/main/java/com/google/phonenumbers/demoapp/result/ResultVpAdapter.java new file mode 100644 index 00000000..812cec72 --- /dev/null +++ b/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/demoapp/app/src/main/res/drawable/ic_launcher_background.xml b/demoapp/app/src/main/res/drawable/ic_launcher_background.xml new file mode 100644 index 00000000..6f91cc79 --- /dev/null +++ b/demoapp/app/src/main/res/drawable/ic_launcher_background.xml @@ -0,0 +1,32 @@ + + + + + + + + + + + + + diff --git a/demoapp/app/src/main/res/drawable/ic_launcher_foreground.xml b/demoapp/app/src/main/res/drawable/ic_launcher_foreground.xml new file mode 100644 index 00000000..d4f2aa4f --- /dev/null +++ b/demoapp/app/src/main/res/drawable/ic_launcher_foreground.xml @@ -0,0 +1,16 @@ + + + + + diff --git a/demoapp/app/src/main/res/drawable/ic_outline_home_30.xml b/demoapp/app/src/main/res/drawable/ic_outline_home_30.xml new file mode 100644 index 00000000..5d774e12 --- /dev/null +++ b/demoapp/app/src/main/res/drawable/ic_outline_home_30.xml @@ -0,0 +1,10 @@ + + + diff --git a/demoapp/app/src/main/res/layout/activity_main.xml b/demoapp/app/src/main/res/layout/activity_main.xml new file mode 100644 index 00000000..cd5f8a58 --- /dev/null +++ b/demoapp/app/src/main/res/layout/activity_main.xml @@ -0,0 +1,83 @@ + + + + + + + +