引论

机场、车站、高速服务区随处可见信息显示屏,有的高悬只做单向显示,有的在地面只可在屏幕上进行操作如导航、查询等,在Andorid之前这块是嵌入式以及Win Embed的领域。现在Android设备的成本以及性能,Andorid App开发技术和开发人员的丰富,用Android驱动信息展示机已是更为合理的趋势。

在这类设备上,绝大多数都是使用物理遮盖或硬件定制,将正常的Andorid系统所必需的控制按键进行了处理,使用过程中普通用户接触不到控制键,达到专用(不可退出,只能使用既定功能)的上的。

我们现在就来看看如何把一个正常的Android设备,通过软件Hack的方式,打造成这类的专用设备。

PS:Google官方的Cardboard应用即属此类,交互方式只剩下一个按垫片了。

分析

Andorid系统本身是为移动设备开发的,包括UI和硬件交互设施:

  • 返回键
  • Home键
  • 应用切换键
  • 音量键
  • 电源键
    android_control_buttons

除了屏蔽按键,应用需要系统启动后自动启动。
UI根据需要按照App正常开发方法进行裁剪。

还需要注意的点:

  • 健全的退出机制(任何情况下都要有的设计,类似后门)
  • 普通用户必须知道如何退出,特别是通过应用商店分发(定制化场景不考虑退出)
  • 软件方式只能覆盖一些应用场景,效果也不是最好,软硬结合才是王道。

Happy Hack

##准备
添加MyApplication全局入口

1
2
3
4
5
6
7
8
<application
android:name="com.geekdev.alpha.hack4andriodbuttons.MyApplication"
android:allowBackup="true"
android:icon="@mipmap/ic_launcher"
android:label="@string/app_name"
android:supportsRtl="true"
android:theme="@style/AppTheme">
</application>

添加一个MainActivity

1
2
3
4
5
6
7
8
9
<activity
android:name="com.geekdev.alpha.hack4andriodbuttons.MainActivity"
android:label="@string/app_name">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>

利用SharedPreference添加一个开关,Preference.java:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
public class PreferenceUtils {
private static final String PREF_NoButtons_MODE = "pref_NoButtons_mode";
public static boolean isNoButtonsModeActive(final Context context) {
SharedPreferences sp = PreferenceManager.getDefaultSharedPreferences(context);
return sp.getBoolean(PREF_NoButtons_MODE, false);
}
public static void setNoButtonsModeActive(final boolean active, final Context context) {
SharedPreferences sp = PreferenceManager.getDefaultSharedPreferences(context);
sp.edit().putBoolean(PREF_NoButtons_MODE, active).commit();
}
}

自启动

在Android.manifest中添加权限和Receiver:

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

添加启动广播Receiver:

1
2
3
4
5
<receiver android:name="com.geekdev.alpha.hack4andriodbuttons.BootReceiver">
<intent-filter>
<action android:name="android.intent.action.BOOT_COMPLETED" />
</intent-filter>
</receiver>

BootReceiver.java:

1
2
3
4
5
6
7
8
9
public class BootReceiver extends BroadcastReceiver {
@Override
public void onReceive(Context context, Intent intent) {
Intent myIntent = new Intent(context, MainActivity.class);
myIntent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
context.startActivity(myIntent);
}
}

返回键

重写MainActivity中的onBackPressed方法:

1
2
3
4
5
@Override
public void onBackPressed() {
Log.i(TAG,"can't back");
}

Home键

重头戏,由于Android不允许改变Home键的行为,所以只能做一些Workaround来绕过限制。
起一个Service,作为守护Service,死循环,每隔3s检查应用是否在前台运行,没有运行则唤醒。由于Android设置了Home键放开后延迟5s才执行Service起动Activity的逻辑,小于5s的轮询都可以,为了绕过这个5s限制,ROOT后的系统把应用移动到/system/app且取”android.permission.STOP_APP_SWITCHES”权限。当然如果硬件上把Home键做处理,就自然不会有这个5s延迟了。
在AndroidManifest中声明Service:

1
2
3
<service
android:name="com.geekdev.alpha.hack4andriodbuttons.NoButtonsService"
android:exported="false" />

在MyApplication应用启动时启动NoButtonsService:

1
2
3
4
5
6
7
8
9
10
11
12
13
public class MyApplication extends Application {
@Override
public void onCreate() {
super.onCreate();
instance = this;
startNoButtonsService();
}
private void startNoButtonsService() {
startService(new Intent(this, NoButtonsService.class));
}
}

在NoButtonsService中实现守护逻辑:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
public class NoButtonsService extends Service {
private static final long INTERVAL = TimeUnit.SECONDS.toMillis(3);
// 3s 轮询
// Home button pressed, user app service has 5s delay in order to make sure user has full choice
//if the system is rooted, move user app to system app, 5s delay removed
private static final String TAG = NoButtonsService.class.getSimpleName();
private Thread t = null;
private Context ctx = null;
private boolean running = false;
@Override
public void onDestroy() {
Log.i(TAG, "Stopping service 'NoButtonsService'");
running =false;
super.onDestroy();
}
@Override
public int onStartCommand(Intent intent, int flags, int startId) {
Log.i(TAG, "Starting service 'NoButtonsService'");
running = true;
ctx = this;
// start a thread that periodically checks if your app is in the foreground
t = new Thread(new Runnable() {
@Override
public void run() {
do {
handleNoButtonsMode();
try {
Thread.sleep(INTERVAL);
} catch (InterruptedException e) {
Log.i(TAG, "Thread interrupted: 'NoButtonsService'");
}
}while(running);
stopSelf();
}
});
t.start();
return Service.START_NOT_STICKY;
}
private void handleNoButtonsMode() {
// is NoButtons Mode active?
if(PreferenceUtils.isNoButtonsModeActive(ctx)) {
// is App in background?
if(isInBackground()) {
restoreApp(); // restore!
}
}
}
private boolean isInBackground() {
ActivityManager am = (ActivityManager) ctx.getSystemService(Context.ACTIVITY_SERVICE);
List<ActivityManager.RunningTaskInfo> taskInfo = am.getRunningTasks(1);
ComponentName componentInfo = taskInfo.get(0).topActivity;
return (!ctx.getApplicationContext().getPackageName().equals(componentInfo.getPackageName()));
}
private void restoreApp() {
// Restart activity
Intent i = new Intent(ctx, MainActivity.class);
i.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
ctx.startActivity(i);
}
@Override
public IBinder onBind(Intent intent) {
return null;
}
}

应用切换键

在MainActivity中重写onPause方法,直接用ActivityManager把MainActivity放在最前:

1
2
3
4
5
6
7
8
9
@Override
protected void onPause() {
super.onPause();
ActivityManager activityManager = (ActivityManager) getApplicationContext()
.getSystemService(Context.ACTIVITY_SERVICE);
activityManager.moveTaskToFront(getTaskId(), 0);
}

别忘了添加权限:

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

音量键

在MainActivity中定义音量键并重写dispatchKeyEvent:

1
2
3
4
5
6
7
8
9
10
11
private final List blockedKeys = new ArrayList(Arrays.asList(KeyEvent.KEYCODE_VOLUME_DOWN, KeyEvent.KEYCODE_VOLUME_UP));
@Override
public boolean dispatchKeyEvent(KeyEvent event) {
if (blockedKeys.contains(event.getKeyCode())) {
//检测到音量键直接return
return true;
} else {
return super.dispatchKeyEvent(event);
}
}

电源键

短按

短按是为了关闭屏幕,监听OnScreenOff的系统广播,在应用启动时注册。
OnScreenOffRecever:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
public class OnScreenOffReceiver extends BroadcastReceiver {
@Override
public void onReceive(Context context, Intent intent) {
if(Intent.ACTION_SCREEN_OFF.equals(intent.getAction())){
MyApplication ctx = (MyApplication) context.getApplicationContext();
// is NoButtons Mode active?
if(PreferenceUtils.isNoButtonsModeActive(ctx)) {
wakeUpDevice(ctx);
}
}
}
private void wakeUpDevice(MyApplication context) {
PowerManager.WakeLock wakeLock = context.getWakeLock(); // get WakeLock reference via MyApplication
if (wakeLock.isHeld()) {
wakeLock.release(); // release old wake lock
}
// create a new wake lock...
wakeLock.acquire();
// ... and release again
wakeLock.release();
}
}

应用启动时注册:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
public class MyApplication extends Application {
private MyApplication instance;
private PowerManager.WakeLock wakeLock;
private OnScreenOffReceiver onScreenOffReceiver;
@Override
public void onCreate() {
super.onCreate();
instance = this;
registerNoButtonsModeScreenOffReceiver();
startNoButtonsService();
}
private void registerNoButtonsModeScreenOffReceiver() {
// register screen off receiver
final IntentFilter filter = new IntentFilter(Intent.ACTION_SCREEN_OFF);
onScreenOffReceiver = new OnScreenOffReceiver();
registerReceiver(onScreenOffReceiver, filter);
}
public PowerManager.WakeLock getWakeLock() {
if(wakeLock == null) {
// lazy loading: first call, create wakeLock via PowerManager.
PowerManager pm = (PowerManager) getSystemService(Context.POWER_SERVICE);
wakeLock = pm.newWakeLock(PowerManager.ACQUIRE_CAUSES_WAKEUP, "wakeup");
}
return wakeLock;
}
private void startNoButtonsService() {
startService(new Intent(this, NoButtonsService.class));
}
}

别忘了添加WAKE_LOCK权限:

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

长按

长按是为了关机,直接把系统的关机弹出对话框处理掉,这里只要系统对话框都关掉。
在MainActivity中重写onWindowFocusChanged焦点变化时:

1
2
3
4
5
6
7
8
9
10
@Override
public void onWindowFocusChanged(boolean hasFocus) {
super.onWindowFocusChanged(hasFocus);
if(!hasFocus) {
// Close every kind of system dialog
Intent closeDialog = new Intent(Intent.ACTION_CLOSE_SYSTEM_DIALOGS);
sendBroadcast(closeDialog);
}
}

防止屏幕自动关闭屏幕

在Layout文件中添加为根视图添加属性:

1
android:keepScreenOn="true"

在MainActivity的onCreate方法中添加,要放在setContentView之前:

1
2
getWindow().addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON);
getWindow().addFlags(WindowManager.LayoutParams.FLAG_DISMISS_KEYGUARD);

开关

开启

放在MainActivity的onResume而不是onCreate,避免开关关闭后,离开应用而应用没有被系统回收,再次打开应用时onCreate没有开启开关。

1
2
3
4
5
@Override
protected void onResume() {
super.onResume();
PreferenceUtils.setNoButtonsModeActive(true, getApplicationContext());
}

关闭

设置一个Button监听事件:

1
2
3
4
5
6
7
8
9
10
Button fab = (Button) findViewById(R.id.hiddenExitButton);
fab.setOnClickListener(new View.OnClickListener() {
@Override
public void onClick(View view) {
PreferenceUtils.setNoButtonsModeActive(false, getApplicationContext());
Snackbar.make(view, "可以退出了", Snackbar.LENGTH_SHORT)
.setAction("Action", null).show();
}
});

结论

我们通过以上Hack步骤,将一个Android设备变成了专用机,可以看到Android以其独特的开放特性可以覆盖足够多的使用场景,也符合Google提出的Android is everywhere的口号。

项目地址:github

Reference

http://developer.android.com/intl/zh-cn/reference/android/view/WindowManager.LayoutParams.html#FLAG_DISMISS_KEYGUARD
http://developer.android.com/intl/zh-cn/training/scheduling/wakelock.html