- PVSM.RU - https://www.pvsm.ru -

Советы и рецепты начинающему Android программисту

Добрый день, уважаемые читатели.

В данной статье я хочу поделиться своим опытом разработки под Android.
Требования к функционалу разрабатываемого продукта породили различные технические задачи, среди которых были как тривиальные, разжеванные во множестве блогов, так и крайне неоднозначные, с неочевидным решением. Я столкнулся с массой вещей, незнакомых мне, как .NET разработчику. Узнал о существовании инструментов, которые значительно упрощают жизнь. Думаю, что каждый начинающий андроидовец проходит похожий путь. Я мог бы сэкономить до трети времени, потраченного на разработку, поиск и эксперименты, имея такую статью.

Поэтому в данном посте я предлагаю вашему вниманию сборник рецептов и советов, которые помогут быстрей и правильней создать Ваше приложение.

Статья предназначена для начинающих, но и опытные разработчики смогут найти полезные моменты. Предполагается, что вы прочитали основы построения Android приложений, знаете о замечательном ресурсе StartAndroid [1] и умеете делать HelloWorld. При этом опыта создания полноценных приложений у вас нет, и вы только что занялись устранением этого недостатка. Для меня же это был первый проект под Android.

Начало

Мы с напарником давно подумывали создать какой-нибудь интересный продукт для Google Play. В один прекрасный день, при прочтении очередной СМС с рекламой такси, возникла идея создать приложение, которое будет бороться с СМС спамом. Это показалось нам интересным, имеющим практическое применение, относительно несложным в реализации для небольшой команды.

Далее был выработан набор конкретных требований и сформирован набор задач, которые надо решить. Самые интересные из них:

Опущены в статье будут следующие моменты:

  • Верстка и дизайн
  • Все, что касается чисто Java кода
  • Архитектура приложения

Подготовка проекта

HelloWorld, и шаблонный проект Android приложения мы создавать уже умеем. Теперь посмотрим, что нам может понадобиться еще. Зная о существовании этих инструментов, легко найти в интернете, как их применять. Ведь основная проблема — это не понять, как использовать инструмент, а узнать, что он вообще есть.

1) ActionBarSherlock [10] необходим для реализации платформонезависимого ActionBar — меню вверху экрана. Качаем с официального сайта и импортируем в Workspace в виде исходников. Просто библиотеки (jar файла) будет недостаточно, так как есть известная проблема с недогрузкой некоторых ресурсов библиотекой.

2) Импортируем в Workspace в виде исходников Google play services из SDK sdkextrasgooglegoogle_play_serviceslibprojectgoogle-play-services_lib. Это понадобится для биллинга и авторизации.

3) Положим в папку lib проекта библиотечки (перед этим найдем их в интернете)
* acra.jar — для реализации механизма отправки отчетов о краше приложения: ACRA [11].
* android-support-v4.jar — для реализации совместимости со старыми версиями Android.
* roboguice-2.0.jar, roboguice-sherlock-1.5.jar — для реализации Dependency Injection, если понравится реализация его в roboguice.
* ormlite-core.jar, ormlite-android.jar — популярная «легковесная» ORM для sqlite базы Android [12].
* joda-time.jar — библиотека для работы с датами.
* jdom.jar, gson.jar — для работы с JSON.
* checkout.jar — для биллинга (выбрал эту библиотечку, Checkout [13] как более удобную, чем работа непосредственно с api).

Получение и разбор CMC

Ниже я приведу способ, работающий на Android с версией ниже 4.4 (KitKat), посколько в данной версии Google радикально поменял подход к обработке CMC. Описание работы с KitKat добавлю позже, когда это будет реализовано мной в приложении.

Для работы с CMC нам нужно разрешение в манифесте:

<uses-permission android:name="android.permission.RECEIVE_SMS" />
<uses-permission android:name="android.permission.WRITE_SMS" />
<uses-permission android:name="android.permission.READ_SMS" />

где
RECEIVE_SMS — разрешение приложению получать CMC.
READ_SMS — разрешение на чтение смс из памяти телефона. Казалось бы, оно нам не нужно, но без этого разрешения не работает запись.
WRITE_SMS — разрешение на запись CMC в память телефона.

Создадим прослушиватель события «принято CMC» SmsBroadcastReceiver. Он будет вызываться в момент получения телефоном СМС и запускать выполнение основных процессов по обработке СМС.

SmsBroadcastReceiver

//BroadcastReceiver обязательный предок прослушивателей системных событий
public class SmsBroadcastReceiver extends BroadcastReceiver {
	@Override
	public void onReceive(Context context, Intent intent) {
		//Интент - входной объект с данными, передаваемый всем прослушивателям
		Bundle bundle = intent.getExtras(); 
		//Извлекаем из словаря некий pdus - в нем информация о СМС
		Object[] pdus = (Object[]) bundle.get("pdus"); 

		if (pdus.length == 0) {
			return; // Что то пошло не так
		}
		// читаем CMC
		Sms sms = SmsFromPdus(pdus, context); 
		// определям, спам ли
		Boolean isClearFromSpam = SuperMegaMethodForResolving Spam(sms, context); 

		if (!isClearFromSpam) {
			// если это спам - прекращаем обработку CMC системой
			abortBroadcast(); 
			return;
		}
	}

	private Sms SmsFromPdus(Object[] pdus, Context context) {
		Sms sms = new Sms();
		for (int i = 0; i < pdus.length; i++) {
			SmsMessage smsMessage = SmsMessage.createFromPdu((byte[]) pdus[i]);
			sms.Text += smsMessage.getMessageBody(); //соберем весь текст (CMC может быть "многостраничной")
		}

		SmsMessage first = SmsMessage.createFromPdu((byte[]) pdus[0]);//из первой страницы получим
		sms.SenderId = first.getOriginatingAddress(); //отправителя
		Date receiveDate = new Date(first.getTimestampMillis()); //дату
		sms.RecieveDate = receiveDate;
		sms.Status = first.getStatus(); //статус (новое, прочтено, доставлено)

		return sms;
	}
}

public class Sms{
	public String SenderId;
	public String Text;
	public Date RecieveDate;
	public int Status;
}

Очень важно, чтобы onReceive отрабатывал менее чем за 10 секунд. Если метод захватывает управление на больший срок, исполнение прерывается и событие отдается другим обработчикам в порядке приоритета.
В моем случае в SuperMegaMethodForResolving происходит проверка на наличие СМС в списке контактов и в локальном списке отправителей, что занимает менее секунды. Затем управление отдается в выделенный поток, а onReceive вызывает abortBroadcast, что не дает другим обработчикам получить СМС (в том числе базовому приложению для СМС).

После нам необходимо подписать SmsBroadcastReceiver на событие приёма CMC. Для этого в блоке application манифеста объявим прослушиватель события android.provider.Telephony.SMS_RECEIVED с именем SmsBroadcastReceiver, который слушает системное событие SMS_RECEIVED и имеет приоритет 2147483631. Приоритет может быть до 2^31. При этом Google не рекомендует использовать значения больше 999. Но многие приложения их используют, а мы хотим, чтобы антиспам перехватывал CMC до того, как оно будет прочтено, например, приложением Contacts+. Это приложение запрашивает самый высокий из известных мне приоритетов.

<receiver
	android:name="su.Jalapeno.AntiSpam.SystemService.SmsBroadcastReceiver"
	android:enabled="true"
	android:exported="true" >
	<intent-filter android:priority="2147483631" >
		<action android:name="android.provider.Telephony.SMS_RECEIVED" />
	</intent-filter>
</receiver>

Автозапуск приложения при загрузке телефона

Запуск приложения после включения устройства может понадобиться для, например, уведомления пользователя о существовании еще непроверенных подозрительных СМС посредством нотификации.

Для автозагрузки потребуется разрешение в манифесте:

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

где
RECEIVE_BOOT_COMPLETED — разрешение слушать событие «загрузка»

Создадим прослушиватель события «загрузка» ServiceBroadcastReceiver.

public class ServiceBroadcastReceiver extends BroadcastReceiver {
	@Override
	public void onReceive(Context context, Intent intent) {
		//Сделаем что-нибудь
	}
}

В нем мы будем выполнять необходимые нам действия после включения телефона.

Затем потребуется подписать ServiceBroadcastReceiver на событие загрузки телефона. Для этого в блоке application манифеста объявим прослушиватель события android.intent.action.BOOT_COMPLETED с именем SmsBroadcastReceiver.

<receiver
	android:name="su.Jalapeno.AntiSpam.SystemService.ServiceBroadcastReceiver"
	android:exported="true" >
	<intent-filter>
		<action android:name="android.intent.action.BOOT_COMPLETED" />
	</intent-filter>
</receiver>

Осуществление веб запросов

Итак, у нас есть CMC, и надо связаться с сервером. Дополним манифест следующим образом:

<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />

где
INTERNET — разрешение отправлять веб запрос.
ACCESS_NETWORK_STATE — разрешение на чтение статуса сети (подключен или нет 3g или wifi).

Осуществление запросов тривиально, для них используем базовый Android http client: org.apache.http.client.HttpClient.

Хранение данных в локальной БД

Делаем все так, как описано здесь [12].

Хранение настроек

Для хранения настроек приложение мы не используем app.config, *.ini или БД приложения, поскольку Android предоставляет нам механизм SharedPreferences [14].

Авторизация посредством токена Google

Это отняло у меня больше всего времени, из-за несистематизированности и неполноты информации, включая документацию Google. Итак, наша задача — получить от Google через приложение подписанный токен с информацией о пользователе и секретными сведениями о приложении. Это даст нам основание полагать, что токен сгенерирован не злоумышленником. Для решения этой задачи используем механизм CrossClientAuth [15].

Необходимо сделать следующее:
1) Получить сертификат приложения и подписать им приложение. Это просто осуществить в Eclipse с помощью мастера. Нажмем правой кнопкой мыши на проекте в Package Explorer -> Android tools -> Export signed application package. Мастер предложит создать новое хранилище сертификата, сгенерировать сертификат и поместить его в хранилище, защищенное указанным нами паролем. Не забудем сохранить хеш сертификата, поскольку он в дальнейшем понадобится.

2) Создать проект в консоли Google [16]. Затем открыть созданный проект и перейти на вкладку Api & auth -> Credentials в панели слева. Здесь необходимо создать пару Client Id для работы серверной части и Android клиента. Жмем Create new Client ID, нам нужен Client ID for Android application.

Советы и рецепты начинающему Android программисту

Заполняем, как указано на скриншоте, указав корректное имя пакета и отпечаток сертификата. После завершения создания получим табличку с информацией и сгенерированным для нас «CLIENT ID». Он понадобится нам на сервере.

Затем создаем новый Client Id типа Web application. В моем случае адреса можно указать произвольно, так как у нас не будет взаимодействия по http с веб ресурсами Google. В результате получим новый CLIENT ID, он уже понадобится на клиенте.

3) Клиентский код в полном виде можно найти в интернете, например GoogleAuthUtil [17]. Отмечу только ключевые моменты: как правильно составить Scope, и откуда взять для него Id

Код

//выбор аккаунта
@Override
protected void onActivityResult(int requestCode, int resultCode, Intent data) {
	if (requestCode == REQUEST_CODE_PICK_ACCOUNT) {
		if (resultCode == RESULT_OK) {
			Email = data.getStringExtra(AccountManager.KEY_ACCOUNT_NAME);
			getUsername();
		}
	}
	super.onActivityResult(requestCode, resultCode, data);
}

private void pickUserAccount() {
	String[] accountTypes = new String[] { "com.google" };
	Intent intent = AccountPicker.newChooseAccountIntent(null, null, accountTypes, false, null, null, null, null);
	startActivityForResult(intent, REQUEST_CODE_PICK_ACCOUNT);
}

//Код получения токена (по хитрому обернутый Try catch и в фоновом потоке
//WEB_CLIENT_ID из Client ID for web application
final private String WEB_CLIENT_ID = "1999999-aaaaaaaaaaaaaaaaaaaaaaaaa.apps.googleusercontent.com";
//"Область" применения Client id. Тут указано что мы хотим аутентификацию
String SCOPE = String.format("audience:server:client_id:%s", WEB_CLIENT_ID);
//Вот и токен
String token = GoogleAuthUtil.getToken(_activity, Email, SCOPE);

Осталось передать токен серверу

4) Серверный код для проверки токена
Используем Microsoft.IdentityModel.Tokens.JWT Nuget [18]. Нижеприведенный код позволяет получить GoogleId юзера и его Email.

Код

public string GetUserIdByJwt(string jwt, out string userEmail)
{
	userEmail = string.Empty;
	string userId = null;
	//Секретный Client ID веб сервиса (Client ID for web application)
	string audience = "111111111111111111-aaaaaaaaaaaaaaaaaaaaa.apps.googleusercontent.com";
	//Секретный Client ID приложения (Client ID for Android application)
	string azp = "1111111111111-aaaaaaaaaaaaaaaaaaaaaaaa.apps.googleusercontent.com";

	var tokenHandler = new JWTSecurityTokenHandler();
	SecurityToken securityToken = tokenHandler.ReadToken(jwt);
	var jwtSecurityToken = securityToken as JWTSecurityToken;

	userEmail = GetClaimValue(jwtSecurityToken, "email");
	userId = GetClaimValue(jwtSecurityToken, "id");

	var validationParameters =
		new TokenValidationParameters()
		{
			AllowedAudience = audience,
			ValidIssuer = "accounts.google.com",
			ValidateExpiration = true,
			//с либами для токенов некоторая неразбериха. 
			//По какой то причине не удалось заставить проверять подпись Google 
			//в токене средствами Microsoft.IdentityModel
			ValidateSignature = false, 
		};

	try
	{
		//Выкинет Exception, если токен не валидный
		ClaimsPrincipal claimsPrincipal = tokenHandler.ValidateToken(jwtSecurityToken, validationParameters);
		//Сверим, что наши Client Id совпадают с токеновскими
		bool allGood = ValidateClaim(jwtSecurityToken, "azp", azp) && ValidateClaim(jwtSecurityToken, "aud", audience);
		if (!allGood)
		{
			userId = null;
		}
	}
	catch
	{
		userId = null;
	}

	return userId;
}

//Сверим значение в Claim с ожидаемым
private static bool ValidateClaim(JWTSecurityToken securityToken, string type, string value)
{
	string claim = GetClaimValue(securityToken, type);

	if (claim == null)
		return false;

	return claim == value;
}

//Получим значение из Claim (по сути KeyValuePair)
private static string GetClaimValue(JWTSecurityToken securityToken, string type)
{
	var claim = securityToken.Claims.SingleOrDefault(x => x.Type == type);

	if (claim == null)
		return null;

	return claim.Value;
}

Работа с механизмом покупок

Для начала необходимо в девелоперской консоли Google [19] у проекта приложения на вкладке КОНТЕНТ ДЛЯ ПРОДАЖИ создать нужные товары. Клиент будем писать на базе примеров Checkout [13]. Здесь приведу выдержки из своего кода, касающиеся биллинга, для более полного понимания библиотеки Checkout.

Изменения в классе Application

public class MyApplication extends Application {
	private static final Products products = Products.create().add(IN_APP, 
		asList("Ид вашего товара из консоли гугл", "Ид вашего товара2 из консоли гугл"));
		
	private final Billing billing = new Billing(this, new Billing.Configuration() {
		@Nonnull
		@Override
		public String getPublicKey() {
			String base64EncodedPublicKey = "ЛИЦЕНЗИОННЫЙ КЛЮЧ ДЛЯ ЭТОГО ПРИЛОЖЕНИЯ, который вы можете взять на вкладке СЛУЖБЫ И API консоли разработчика";

			return base64EncodedPublicKey;
		}

		@Nullable
		@Override
		public Cache getCache() {
			return Billing.newCache();
		}
	});
	
	@Nonnull
	private final Checkout checkout = Checkout.forApplication(billing, products);
	@Nonnull
	private static MyApplication instance;

	public MyApplication() {
		instance = this;
	}

	@Override
	public void onCreate() {
		super.onCreate();
		billing.connect();
	}

	@Nonnull
	public static MyApplication get() {
		return instance;
	}

	@Nonnull
	public Checkout getCheckout() {
		return checkout;
	}
}

Активити с покупкой

public class BillingActivity extends RoboSherlockActivity {
	private Sku _skuAccess;
	
	@Nonnull
	protected final ActivityCheckout checkout = Checkout.forActivity(this,
			MyApplication.get().getCheckout());
	@Nonnull
	protected Inventory inventory;

	@Override
	protected void onCreate(Bundle savedInstanceState) {
		super.onCreate(savedInstanceState);
		_skuAccess = null;
		_activity = this;
		checkout.start();
		checkout.createPurchaseFlow(new PurchaseListener());
		inventory = checkout.loadInventory();
		inventory.whenLoaded(new InventoryLoadedListener());
	}
	
	@Override
	protected void onActivityResult(int requestCode, int resultCode, Intent data) {
		super.onActivityResult(requestCode, resultCode, data);
		checkout.onActivityResult(requestCode, resultCode, data);
	}
	
	@Override
	protected void onDestroy() {
		checkout.stop();
		checkout.destroyPurchaseFlow();
		super.onDestroy();
	}

	@Nonnull
	public ActivityCheckout getCheckout() {
		return checkout;
	}

	public void Buy(View view) {
		purchase(_skuAccess);
	}
	
	private void purchase(@Nonnull final Sku sku) {
		boolean billingSupported = checkout.isBillingSupported(IN_APP);
		if (!billingSupported) {
			return;
		}

		checkout.whenReady(new Checkout.ListenerAdapter() {
			@Override
			public void onReady(@Nonnull BillingRequests requests) {
				requests.purchase(sku, null, checkout.getPurchaseFlow());
			}
		});
	}

	private class PurchaseListener extends BaseRequestListener<Purchase> {
		@Override
		public void onSuccess(@Nonnull Purchase purchase) {
			onPurchased();
		}

		private void onPurchased() {
			//перегрузим инвентарь - и после загрузки инвентаря проверим покупку и обработаем это в приложении
			inventory.load().whenLoaded(new InventoryLoadedListener());
		}

		@Override
		public void onError(int response, @Nonnull Exception ex) {
			// it is possible that our data is not synchronized with data on
			// Google Play => need to handle some errors
			if (response == ResponseCodes.ITEM_ALREADY_OWNED) {
				onPurchased();
			} else {
				super.onError(response, ex);
			}
		}
	}

	private class InventoryLoadedListener implements Inventory.Listener {
		private String _purchaseOrderId;

		@Override
		public void onLoaded(@Nonnull Inventory.Products products) {
			final Inventory.Product product = products.get(IN_APP);
			if (product.isSupported()) {
				boolean isPurchased = InspectPurchases(product);
				//Делаем что-нибудь
			}
		}

		private boolean InspectPurchases(Product product) {
			List<Sku> skus = product.getSkus();
			Sku sku = skus.get(0); //допустим один товар
			final Purchase purchase = product.getPurchaseInState(sku,
						Purchase.State.PURCHASED);
			boolean isPurchased = purchase != null
						&& !TextUtils.isEmpty(purchase.token);						
			if (isPurchased) {
				//Может уже куплено?
				return true;
			}
			else {
				//если нет - запомним товар для покупки
				_skuAccess = sku;
				return false;
			}
		}
	}

	private abstract class BaseRequestListener<Req> implements
			RequestListener<Req> {

		@Override
		public void onError(int response, @Nonnull Exception ex) {
		}
	}
}

Заключение

На этом пока все. Если Вам захочется подробней разобрать какой-либо аспект — напишите и я дополню статью. Надеюсь, пост поможет начинающим Android программистам наломать меньше граблей, не наступать на дрова и сэкономить время.

Автор: alexus_ru

Источник [20]


Сайт-источник PVSM.RU: https://www.pvsm.ru

Путь до страницы источника: https://www.pvsm.ru/android/71389

Ссылки в тексте:

[1] StartAndroid: http://startandroid.ru/ru/uroki/vse-uroki-spiskom

[2] Подготовка проекта: #Prepare

[3] Получение и разбор CMC: #Sms

[4] Автозапуск приложения при загрузке телефона: #Autorun

[5] Осуществление веб запросов: #Web

[6] Хранение данных в локальной БД: #Sqlite

[7] Хранение настроек: #Preference

[8] Авторизация посредством токена Google: #Token

[9] Работа с механизмом покупок: #Billing

[10] ActionBarSherlock: http://actionbarsherlock.com/

[11] ACRA: https://github.com/ACRA/acra/wiki/BasicSetup

[12] ORM для sqlite базы Android: http://habrahabr.ru/post/143431/

[13] Checkout: http://habrahabr.ru/post/233265/

[14] SharedPreferences: http://startandroid.ru/ru/uroki/vse-uroki-spiskom/73-urok-33-hranenie-dannyh-preferences.html

[15] CrossClientAuth: https://developers.google.com/accounts/docs/CrossClientAuth

[16] консоли Google: https://console.developers.google.com/project

[17] GoogleAuthUtil: http://developer.android.com/reference/com/google/android/gms/auth/GoogleAuthUtil.html

[18] Nuget: https://www.nuget.org/packages/System.IdentityModel.Tokens.Jwt/

[19] консоли Google: https://play.google.com/apps/publish/

[20] Источник: http://habrahabr.ru/post/240045/