
Привет хабрастарожилам от хабрановичка. Ровно год назад я решил написать кастомный локер (экран блокировки) для моего старичка Samsung Galaxy Gio в стиле популярного тогда Samsung Galaxy s3. Какие причины заставили меня это сделать, писать не буду, но добавлю лишь то, что в Google Play я программу не собирался выкладывать и каким-либо другим способом заработать на ней не планировал. Данный пост посвящен последствиям моего решения.
Начну издалека. Многие хвалят Android за открытость и возможность заменить и настроить встроенные программы под свои нужды. Что тут сказать? В сравнении с другими популярными ОС, это, безусловно, так, но если копнуть глубже в архитектуру Android возникают трудности и вопросы. Локскрин (в Android это называется keyguard) как раз и вызывает вопросы: почему Google не поступили с ним, так как с лаунчерами, почему не сделали диалог со всеми доступными на устройстве локерами и с возможностью выбрать нужный по умолчанию? Где-то в глубине мозга тихим нерешительным голосом кто-то отвечает: может быть Google (Android Ink. если быть точнее) поступил так из соображений безопасности. Этот голос вероятно прав и многим разработчикам локеров и мне (скромность не позволила приписать себя к их числу) пришлось изобретать велосипед, и не один.
Изучаем исходники
Начал я с использования одного из плюсов Android – из изучения исходников. Я один из тех консерваторов, которые уже 2,5 года сидят на стоковой прошивке (2.3.6), поэтому и исходники изучал соответствующие. Классы, отвечающие за блокировку экрана, лежат в android.policy.jar, что в system/framework. Первоначальной целью было найти «точку входа», т.е. где и когда вызывается локер. Искал здесь.
В классе PhoneWindowManager.java есть метод screenTurnedOff(int why), который вызывает одноименный метод класса KeyguardViewMediator. Проследив, кто кого вызывает, я нашел метод в классе KeyguardViewManager, создающий непосредственно View стокового локера.
public synchronized void show() {
if (DEBUG) Log.d(TAG, "show(); mKeyguardView==" + mKeyguardView);
if (mKeyguardHost == null) {
if (DEBUG) Log.d(TAG, "keyguard host is null, creating it...");
mKeyguardHost = new KeyguardViewHost(mContext, mCallback);
final int stretch = ViewGroup.LayoutParams.MATCH_PARENT;
int flags = WindowManager.LayoutParams.FLAG_FORCE_NOT_FULLSCREEN
| WindowManager.LayoutParams.FLAG_SHOW_WALLPAPER
| WindowManager.LayoutParams.FLAG_KEEP_SURFACE_WHILE_ANIMATING
/*| WindowManager.LayoutParams.FLAG_LAYOUT_IN_SCREEN
| WindowManager.LayoutParams.FLAG_LAYOUT_INSET_DECOR*/ ;
if (!mNeedsInput) {
flags |= WindowManager.LayoutParams.FLAG_ALT_FOCUSABLE_IM;
}
WindowManager.LayoutParams lp = new WindowManager.LayoutParams(
stretch, stretch, WindowManager.LayoutParams.TYPE_KEYGUARD,
flags, PixelFormat.TRANSLUCENT);
lp.softInputMode = WindowManager.LayoutParams.SOFT_INPUT_ADJUST_PAN;
lp.windowAnimations = com.android.internal.R.style.Animation_LockScreen;
lp.screenOrientation = ActivityInfo.SCREEN_ORIENTATION_NOSENSOR;
lp.setTitle("Keyguard");
mWindowLayoutParams = lp;
mViewManager.addView(mKeyguardHost, lp);
}
if (mKeyguardView == null) {
if (DEBUG) Log.d(TAG, "keyguard view is null, creating it...");
mKeyguardView = mKeyguardViewProperties.createKeyguardView(mContext, mUpdateMonitor, this);
mKeyguardView.setId(R.id.lock_screen);
mKeyguardView.setCallback(mCallback);
final ViewGroup.LayoutParams lp = new FrameLayout.LayoutParams(
ViewGroup.LayoutParams.MATCH_PARENT,
ViewGroup.LayoutParams.MATCH_PARENT);
mKeyguardHost.addView(mKeyguardView, lp);
if (mScreenOn) {
mKeyguardView.onScreenTurnedOn();
}
}
mKeyguardHost.setVisibility(View.VISIBLE);
mKeyguardView.requestFocus();
}
Что ж, все гениальное – просто. Решил повторить этот код для своего приложения и получил ошибку – нет нужного permission. Немного погуглив, добавил следующие разрешения: SYSTEM_ALERT_WINDOW и INTERNAL_SYSTEM_WINDOW. Это не помогло.
Вернулся к изучению класса PhoneWindowManager.java:
public int checkAddPermission(WindowManager.LayoutParams attrs) {
int type = attrs.type;
if (type < WindowManager.LayoutParams.FIRST_SYSTEM_WINDOW || type > WindowManager.LayoutParams.LAST_SYSTEM_WINDOW) {
return WindowManagerImpl.ADD_OKAY;
}
String permission = null;
switch (type) {
case TYPE_TOAST:
// XXX right now the app process has complete control over
// this... should introduce a token to let the system
// monitor/control what they are doing.
break;
case TYPE_INPUT_METHOD:
case TYPE_WALLPAPER:
// The window manager will check these.
break;
case TYPE_PHONE:
case TYPE_PRIORITY_PHONE:
case TYPE_SYSTEM_ALERT:
case TYPE_SYSTEM_ERROR:
case TYPE_SYSTEM_OVERLAY:
permission = android.Manifest.permission.SYSTEM_ALERT_WINDOW;
break;
default:
permission = android.Manifest.permission.INTERNAL_SYSTEM_WINDOW;
}
if (permission != null) {
if (mContext.checkCallingOrSelfPermission(permission) != PackageManager.PERMISSION_GRANTED) {
return WindowManagerImpl.ADD_PERMISSION_DENIED;
}
}
return WindowManagerImpl.ADD_OKAY;
}
Для требуемого окна TYPE_KEYGUARD нужно второе из моих добавленных разрешений. Задней точкой тела начал ощущать, что не все так просто, как я себе представлял. Решено было посмотреть на описание этого permission. Вот выдержка из AndroidManifest.xml пакета framework-res.apk.
<permission android:label="@string/permlab_internalSystemWindow"
android:name="android.permission.INTERNAL_SYSTEM_WINDOW"
android:protectionLevel="signature"
android:description="@string/permdesc_internalSystemWindow" />
Вот она – черная полоса в жизни. Ведь я понимал, «signature» – это значит, что использовать этот пермишн может только пакет, подписанный тем же ключом, что и пакет, выдавший это разрешение (в нашем случае — framework-res.apk). Ладно, достаем инструменты для изготовления велосипедов.
Версия один
Первым решением было использовать activity в качестве локскри��а. На stackoverflow советуют использовать следующий код:
@Override
public void onAttachedToWindow(){
getWindow().setType(WindowManager.LayoutParams.TYPE_KEYGUARD);
super.onAttachedToWindow();
}
Признаюсь, в первых версиях я использовал этот метод. У него есть существенные недостатки: статусбар не блокируется, начиная с версии API11 этот метод не работает.
Решение первого недостатка (переполнениестека опять помогло) следующее. Поверх статусбара с помощью WindowManager рисуется прозрачный View, который перехватывает все TouchEvent. Вот служба, реализующая это:
public class StatusbarService extends Service {
View v;
@Override
public void onStart(Intent intent, int id) {
super.onStart(intent, id);
Bundle e = intent.getExtras();
if(e != null){
int statusBarHeight = (Integer) e.get("SBH");
WindowManager.LayoutParams lp = new WindowManager.LayoutParams(WindowManager.LayoutParams.FILL_PARENT, statusBarHeight, WindowManager.LayoutParams.TYPE_SYSTEM_ALERT, WindowManager.LayoutParams.FLAG_NOT_FOCUSABLE | WindowManager.LayoutParams.FLAG_NOT_TOUCH_MODAL | WindowManager.LayoutParams.FLAG_LAYOUT_IN_SCREEN, PixelFormat.TRANSLUCENT);
lp.gravity = Gravity.TOP;
WindowManager wm = (WindowManager) getSystemService(WINDOW_SERVICE);
v = new View(getBaseContext());
wm.addView(v, lp);
}
}
@Override
public void onDestroy() {
super.onDestroy();
WindowManager wm = (WindowManager) getSystemService(WINDOW_SERVICE);
wm.removeView(v);
}
@Override
public IBinder onBind(Intent arg0) {
return null;
}
}
Второго недостатка для меня не существовало, на Gingerbread данный код работал превосходно. На 4pda, куда я опрометчиво выложил свое творение, пользователи жаловались, что на многих телефонах мой локер сворачивался как обычное приложение. Для них найдено такое решение. В качестве стандартного лаунчера устанавливается пустышка. При нажатии кнопки HOME система вызывает мой лаунчер-пустышку. Если кастомный локер активен, лаунчер сразу же закрывается в методе onCreate(), т.е. визуально нажатие кнопки HOME ни к чему не приводит. Если кастомный локер не активен, мой лаунчер тут же вызывает другой правильный лаунчер, который пользователь указал в настройках.
Вот код пустышки:
public class HomeActivity extends Activity {
@Override
public void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
if(MainService.unlocked != false){
try{
SharedPreferences pref = PreferenceManager.getDefaultSharedPreferences(getBaseContext());
String pn = pref.getString("settings_launcher_pn", "");
String an = pref.getString("settings_launcher_an", "");
Intent launch = new Intent(Intent.ACTION_MAIN);
launch.addCategory(Intent.CATEGORY_HOME);
launch.addFlags(Intent.FLAG_ACTIVITY_EXCLUDE_FROM_RECENTS);
launch.setClassName(pn, an);
startActivity(launch);
} catch(Exception e){
Intent i = null;
PackageManager pm = getPackageManager();
for(ResolveInfo ri:pm.queryIntentActivities(new Intent(Intent.ACTION_MAIN).addCategory(Intent.CATEGORY_HOME), PackageManager.MATCH_DEFAULT_ONLY)){
if(!getPackageName().equals(ri.activityInfo.packageName)){
i = new Intent().addCategory(Intent.CATEGORY_HOME).setAction(Intent.ACTION_MAIN).addFlags(Intent.FLAG_ACTIVITY_EXCLUDE_FROM_RECENTS).setClassName(ri.activityInfo.packageName, ri.activityInfo.name);
}
}
if(i != null) startActivity(i);
}
}
finish();
}
}
Выглядело это следующим образом:

Эти велосипеды ездили долго и хорошо, пока я не решил сделать «правильный» локскрин, и уже в стиле Samsung Galaxy S4.
Версия два
Когда системе необходимо запускать кастомный локер? Очевидно, что при выключении экрана. Создадим службу, регистрирующую BroadcastReceiver, т.к. из манифеста д��нный фильтр не работает.
Необходимо учесть две особенности:
1. Служба должна быть запущена в момент загрузки устройства. Создадим BroadcastReseiver с IntentFilter «android.intent.action.BOOT_COMPLETED». Есть одно НО: служба при запуске должна отключить стандартную блокировку экрана. Особенностью Android является то, что стандартное окно ввода PIN-кода является частью стокового экрана блокировки. Поэтому служба должна запускаться только когда PIN буден введен.
Максимум, на что хватило моей фантазии:
public class BootReceiver extends BroadcastReceiver {
@Override
public void onReceive(Context context, Intent intent) {
TelephonyManager tm = (TelephonyManager)context.getSystemService(Context.TELEPHONY_SERVICE);
if(PreferenceManager.getDefaultSharedPreferences(context).getBoolean("unlock_screen_enable", false)){
if(tm.getSimState() != TelephonyManager.SIM_STATE_PIN_REQUIRED && tm.getSimState() != TelephonyManager.SIM_STATE_PUK_REQUIRED){
context.startService(new Intent(context, KeyguardService.class));
} else {
AlarmManager alarms = (AlarmManager)context.getSystemService(Context.ALARM_SERVICE);
Intent intentToFire = new Intent(context, BootReceiver.class);
PendingIntent alarmIntent = PendingIntent.getBroadcast(context, 0, intentToFire, 0);
alarms.set(AlarmManager.RTC_WAKEUP, System.currentTimeMillis() + 10000, alarmIntent);
}
}
}
}
2. Проанализировав PhoneWindowManager видно, что в метод screenTurnedOff(int why) передается переменная why, принимающая 3 значения:
— экран выключился по истечению таймаута (в этом случае стоковый локер запускается с задержкой),
— экран выключился при срабатывании сенсора приближения (во время телефонного разговора),
— экран выключился при нажатии кнопки.
В моем случае такого разнообразия нет. Поэтому служба мониторит состояние телефона, и при входящем звонке или во время разговора экран не блокируется.
Вот основной код службы:
public class KeyguardService extends Service {
KeyguardMediator keyguardMediator;
KeyguardManager.KeyguardLock keyguardLock;
boolean telephone = false; //false - no call, true - in call
boolean wasLocked = false;
@Override
public void onCreate() {
super.onCreate();
TelephonyManager telephonyManager = (TelephonyManager)getSystemService(Context.TELEPHONY_SERVICE);
telephonyManager.listen(new MyPhoneStateListener(), PhoneStateListener.LISTEN_CALL_STATE);
keyguardLock = ((KeyguardManager)getSystemService(KEYGUARD_SERVICE)).newKeyguardLock("Custom keyguard by Arriva");
keyguardLock.disableKeyguard();
IntentFilter filter = new IntentFilter();
filter.addAction(Intent.ACTION_SCREEN_OFF);
registerReceiver(receiver, filter);
keyguardMediator = new KeyguardMediator(this);
}
@Override
public void onDestroy() {
super.onDestroy();
unregisterReceiver(receiver);
keyguardLock.reenableKeyguard();
keyguardLock = null;
keyguardMediator.destroy();
}
void changeTelephoneState(int state){
if(state == TelephonyManager.CALL_STATE_IDLE){
telephone = false;
if(wasLocked){
wasLocked = false;
keyguardMediator.visibility(true);
}
} else {
telephone = true;
if(keyguardMediator.isShowing){
wasLocked = true;
keyguardMediator.visibility(false);
}
}
}
private BroadcastReceiver receiver = new BroadcastReceiver(){
@Override
public void onReceive(Context context, Intent intent) {
String settingsLock = PreferenceManager.getDefaultSharedPreferences(context).getString("screen_lock", "2");
if(!settingsLock.equals("1")){
keyguardMediator.show();
}
}
};
class MyPhoneStateListener extends PhoneStateListener {
@Override
public void onCallStateChanged(int state, String incomingNumber){
super.onCallStateChanged(state, incomingNumber);
changeTelephoneState(state);
}
}
}
Идея не использовать activity, а использовать WindowManager была еще сильна. Из пяти типов окон, использующих разрешение SYSTEM_ALERT_WINDOW, мне подошел TYPE_SYSTEM_ALERT. Причем у него были очевидные достоинства: блокировался статусбар (по крайней мере, на Gingerbread) и перехватывалось нажатие кнопки HOME (работает даже на Jelly Bean).
Промежуточным звеном между службой и KeyguardView является класс KeyguardMediator:
public class KeyguardMediator {
WindowManager windowManager;
KeyguardHost keyguardHost;
KeyguardView keyguardView;
Context context;
boolean isShowing;
String[] prefShortcutsArray;
String prefScreenLock;
String prefUnlockEffect;
String prefPatternPassword;
boolean prefMultipleWidgets;
boolean prefShortcuts;
boolean prefHelpText;
boolean prefPatternVisible;
boolean prefWallpaper;
boolean drawWallpaperView;
boolean drawWallpaperViewSqueeze;
public KeyguardMediator(Context con){
context = con;
windowManager = (WindowManager)context.getSystemService(Context.WINDOW_SERVICE);
// Этот класс ко всему прочему хранит еще и настройки
}
void onResume(){
if(keyguardView != null){
keyguardView.onResume();
}
}
void onPause(){
if(keyguardView != null){
keyguardView.onPause();
}
}
void show(){
if(isShowing){
visibility(true);
return;
}
keyguardView = new KeyguardView(context, this);
isShowing = true;
int flags = WindowManager.LayoutParams.FLAG_FORCE_NOT_FULLSCREEN;
if(!drawWallpaperView) {
flags |= WindowManager.LayoutParams.FLAG_SHOW_WALLPAPER;
}
int format = PixelFormat.OPAQUE;
if(!drawWallpaperView) {
format = PixelFormat.TRANSLUCENT;
}
WindowManager.LayoutParams lp = new WindowManager.LayoutParams(WindowManager.LayoutParams.FILL_PARENT, WindowManager.LayoutParams.FILL_PARENT, WindowManager.LayoutParams.TYPE_SYSTEM_ALERT, flags, format);
if(drawWallpaperView){
lp.windowAnimations = android.R.style.Animation_Toast;
// Можно использовать только стандартную анимацию
}
lp.screenOrientation = ActivityInfo.SCREEN_ORIENTATION_NOSENSOR;
lp.setTitle("Custom keyguard");
keyguardHost = new KeyguardHost(context);
keyguardHost.addView(keyguardView);
windowManager.addView(keyguardHost, lp);
}
void hide(){
if(!isShowing){
return;
}
isShowing = false;
keyguardHost.setVisibility(View.GONE);
// Прежде чем удалить View необходимо сделать его невидимым, тогда он исчезнет с анимацией
windowManager.removeView(keyguardHost);
keyguardHost = null;
keyguardView = null;
}
void visibility(boolean visible){
// Во время звонка View становится невидимым
keyguardHost.setVisibility(visible ? View.VISIBLE : View.GONE);
if(keyguardView != null){
if(visible){
keyguardView.onResume();
} else {
keyguardView.onPause();
}
}
}
void startWidgetPicker(){
// Запускает activity выбора виджетов
}
void finishWidgetPicker(){
// Перенаправляет результат layout'у с виджетами
}
void destroy(){
if(keyguardHost != null){
windowManager.removeView(keyguardHost);
keyguardHost = null;
keyguardView = null;
}
}
}
Дальше история становится менее интересной, так сказать, будничной. На мой локер можно добавлять ярлыки приложений (здесь все стандартно и просто) и виджеты (а вот этот момент достоин отдельной статьи).
Теперь все стало выглядеть современней:

Заключение
Данным постом я не хотел себя пропиарить. Это не руководство для написания локеров. Я лишь просто хотел показать, как человек, поленившийся прочитать хотя бы одну книгу по основам Java, но практикующийся в написании программок в течении двух лет, может исхитриться для получения конкретного результата.
