Pull to refresh

Как я писал кастомный локер

Reading time11 min
Views35K


Привет хабрастарожилам от хабрановичка. Ровно год назад я решил написать кастомный локер (экран блокировки) для моего старичка 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, но практикующийся в написании программок в течении двух лет, может исхитриться для получения конкретного результата.
Tags:
Hubs:
Total votes 39: ↑35 and ↓4+31
Comments8

Articles