Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add a demo Android App that uses LPN in Java. #3135

Merged
merged 4 commits into from
Aug 22, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
7 changes: 7 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
51 changes: 51 additions & 0 deletions java/demoapp/README.md
Original file line number Diff line number Diff line change
@@ -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`)
45 changes: 45 additions & 0 deletions java/demoapp/app/build.gradle
Original file line number Diff line number Diff line change
@@ -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'
}
39 changes: 39 additions & 0 deletions java/demoapp/app/src/main/AndroidManifest.xml
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools">

<uses-permission android:name="android.permission.READ_CONTACTS" />
<uses-permission android:name="android.permission.WRITE_CONTACTS" />

<application
android:name=".MyApplication"
android:allowBackup="true"
android:icon="@mipmap/ic_launcher"
android:label="@string/app_name"
android:supportsRtl="true"
android:taskAffinity=""
android:theme="@style/AppTheme"
tools:targetApi="33">
<activity
android:name=".main.MainActivity"
android:exported="true">
<intent-filter>
<action android:name="android.intent.action.MAIN" />

<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>

<meta-data
android:name="android.app.lib_name"
android:value="" />
</activity>
<activity
android:name=".result.ResultActivity"
android:exported="false">
<intent-filter>
<action android:name="android.intent.action.DEFAULT" />
</intent-filter>
</activity>
</application>

</manifest>
Original file line number Diff line number Diff line change
@@ -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);
}
}
Original file line number Diff line number Diff line change
@@ -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
}
}
Original file line number Diff line number Diff line change
@@ -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<PhoneNumberInApp> getAllPhoneNumbersSorted(Context context) {
ArrayList<PhoneNumberInApp> 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;
}
}