Pull to refresh

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

Reading time 12 min
Views 78K
Добрый день, уважаемые хабраюзеры.

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

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

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

Начало


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

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

Опущены в статье будут следующие моменты:
  • Верстка и дизайн
  • Все, что касается чисто Java кода
  • Архитектура приложения

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


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

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

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

3) Положим в папку lib проекта библиотечки (перед этим найдем их в интернете)
* acra.jar — для реализации механизма отправки отчетов о краше приложения: ACRA.
* 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.
* joda-time.jar — библиотека для работы с датами.
* jdom.jar, gson.jar — для работы с JSON.
* checkout.jar — для биллинга (выбрал эту библиотечку, Checkout как более удобную, чем работа непосредственно с 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.

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


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

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


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

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


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

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

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



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

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

3) Клиентский код в полном виде можно найти в интернете, например GoogleAuthUtil. Отмечу только ключевые моменты: как правильно составить 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. Нижеприведенный код позволяет получить 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 у проекта приложения на вкладке КОНТЕНТ ДЛЯ ПРОДАЖИ создать нужные товары. Клиент будем писать на базе примеров Checkout. Здесь приведу выдержки из своего кода, касающиеся биллинга, для более полного понимания библиотеки 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 программистам наломать меньше граблей, не наступать на дрова и сэкономить время. Опробовать готовое приложение можно тут.
Tags:
Hubs:
+24
Comments 60
Comments Comments 60

Articles