Привет хабрастарожилам от хабрановичка. Ровно год назад я решил написать кастомный локер (экран блокировки) для моего старичка 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, но практикующийся в написании программок в течении двух лет, может исхитриться для получения конкретного результата.