スケジュール管理やアラーム機能を提供するアプリのテストで、端末の時刻を自由に変更できたら便利です。
ですので、端末の時刻を変更するテストルールを作成してみました。
この記事は、以前に投稿した「Android端末の時刻を変更するJUnitテストルール」を改訂したものです。
API23~34で動作するように、テストルールの記述を改良しています。
※環境:Android Studio Jellyfish | 2023.3.1
Kotlin 1.9.0
Compose Compiler 1.5.1
androidx.test.uiautomator:uiautomator 2.3.0
androidx.test.ext:junit 1.1.5
androidx.test:rules 1.5.0
junit:junit 4.13.2
目次
時刻を変更する方法
時刻を変更する方法として、次の3つがあげられます。
- dateツールを使う
- プログラム中から行う
- SettingsアプリをGUI操作
dateツールを使う
adb shellコマンドでAndroidの仮想端末へ接続し、dateツールを使って変更します。
使い方は次の通りです。コマンドライン中に“SET”があれば時刻を設定し、無ければ現在時刻を表示します。
# date -h usage: date [-u] [-r FILE] [-d DATE] [+DISPLAY_FORMAT] [-D SET_FORMAT] [SET] Set/get the current date/time. With no SET shows the current date. Default SET format is "MMDDhhmm[[CC]YY][.ss]" ※MM ... 月、DD ... 日、hh ... 時、mm ... 分
例えば、12月31日23時59分に変更してみましょう。
# date Fri May 3 07:03:04 GMT 2024 # date 12312359 Tue Dec 31 23:59:00 GMT 2024 # date Tue Dec 31 23:59:02 GMT 2024
ただし、この方法は「Root権限が必要」です。
UiAutomatorのUiDeviceからadb shellを発行できますが、この場合はRootになれないので使えません。
プログラム中から行う
システムのリソースへアクセスする権限を、プログラム(アプリ)へ持たせる必要があります。
しかし、一般の開発者に、このようなプログラミングは許されていません。
ですので、この方法は不可能です。
端末にプリインストールされているSettingsアプリは、この権限を持ちます。端末のベンダーがアプリの実装を行っているからです。
SettingsアプリをGUI操作
UiAutomatorのUiDeviceを使うと、ユーザが行う端末の操作と同等なことが、プログラム中からできます。
これを使い、Settingsアプリを開いてGUI操作で時刻を変更します。
時刻変更のピッカー
時刻変更のGUIには、2タイプのピッカーがあります。ダイヤルとドラムタイプです。
![ダイヤルタイプのピッカー](https://android.suzu-sd.com/wp-content/uploads/2024/05/TimePicker_DialType.png)
![ドラムタイプのピッカー](https://android.suzu-sd.com/wp-content/uploads/2024/05/TimePicker_DrumType.png)
実機において、採用するピッカーはSettingsアプリの実装を行うベンダーが決めます。
Settingアプリはプリインストールされるため、OSのバージョンアップ以外でピッカーが変わることはありません。
しかし、AVD(エミュレータ)において、採用するピッカーは起動時に決まるようです。
ピッカーの選択に法則性が見つけられません。ですので、ランダムなのかも知れません。
ピッカーのタイプはリソース名により判別が可能です。
/* Pickerのタイプ */ internal enum class PickerType(val res: String) { DIAL("android:id/radial_picker"), CALENDAR("android:id/date_picker_day_picker"), DRUM("android:id/numberpicker_input") } internal fun getTimePickerType( uiDev: UiDevice = UiDevice.getInstance(InstrumentationRegistry.getInstrumentation()) ): PickerType { return if(hasObjByRes("android:id/timePicker")) { val _isDrum = uiDev.hasObject(By.res(PickerType.DRUM.res)) val _isDial = uiDev.hasObject(By.res(PickerType.DIAL.res)) when { _isDrum -> PickerType.DRUM _isDial -> PickerType.DIAL else -> throw UiObjectNotFoundException("Unknown picker !") } } else throw UiObjectNotFoundException("Picker not found !") }
ピッカーの操作
UiAutomatorのUiDeviceにより、ピッカーを操作します。
ドラムタイプは特別な処理が必要です。
- AMPMの境界(例:23時⇒0時、24時制)を跨がない。
⇒ AMPMドラムの繰り上がりを避けるためです。 - 時の境界(例:59分⇒0分)を跨がない。
⇒ 時ドラムの繰り上がりを避けるためです。
/** * ダイヤルタイプのピッカー */ private const val OutsideRingRatio = 0.8 // 24表記の文字の位置 private const val InsideRingRatio = 0.5 // 同上 internal fun setTime24WithDial( hour: Int, min: Int, uiDev: UiDevice = UiDevice.getInstance(InstrumentationRegistry.getInstrumentation()) ){ require(hour >= 0) {"year >= 0"} require(min >= 0) {"month >= 0"} when(val _tHour = hour % 24) { 0 -> setHourWithDial(uiDev, InsideRingRatio, 12, 0) in 1..11 -> setHourWithDial(uiDev, OutsideRingRatio, 12, _tHour) 12 -> setHourWithDial(uiDev, OutsideRingRatio, 12, 12) in 13..23 -> setHourWithDial(uiDev, InsideRingRatio, 12, _tHour % 12) } val _tMin = min % 60 setMinWithDial(uiDev, OutsideRingRatio, 60, _tMin) } internal fun setTime12WithDial( hour: Int, min: Int, uiDev: UiDevice = UiDevice.getInstance(InstrumentationRegistry.getInstrumentation()) ){ require(hour >= 0) {"year >= 0"} require(min >= 0) {"month >= 0"} val _tHour = hour % 12 setHourWithDial(uiDev, OutsideRingRatio, 12, _tHour) val _tMin = min % 60 setMinWithDial(uiDev, OutsideRingRatio, 60, _tMin) val _tAM = if(hour / 12 % 2 == 0) "AM" else "PM" findObjByTextAndClick(_tAM, uiDev) } private fun setHourWithDial( uiDev: UiDevice, ringRatio: Double, resolution: Int, position: Int ) { val _radial = findObjByRes("android:id/radial_picker", uiDev) val _rect = _radial.visibleBounds val _center = _radial.visibleCenter val _radius = (_rect.height().toDouble() / 2.0) * ringRatio _radial.recycle() val _andle = 2.0 * PI * (position.toDouble() / resolution.toDouble()) val _x = _radius * cos(_andle - PI / 2.0) // 3時の位置が 0[rad]、0時の位置へ補正 val _y = _radius * sin(_andle - PI / 2.0) uiDev.click(_center.x + _x.roundToInt(), _center.y + _y.roundToInt()) uiDev.waitForIdle(2000) } private fun setMinWithDial( uiDev: UiDevice, ringRatio: Double, resolution: Int, position: Int ) { val _radial = findObjByRes("android:id/radial_picker", uiDev) val _rect = _radial.visibleBounds val _center = _radial.visibleCenter val _radius = (_rect.height().toDouble() / 2.0) * ringRatio _radial.recycle() // 文字盤の文字は、近いほど吸着力が強い // ⇒ 文字へ吸着しないように、文字との距離を調整 val _toPosition = when(position % 5) { 1 -> position.toDouble() + 0.3 2 -> position.toDouble() 3 -> position.toDouble() 4 -> position.toDouble() - 0.3 else -> position.toDouble() } val _toAndle = 2.0 * PI * (_toPosition / resolution.toDouble()) val _toX = _radius * cos(_toAndle - PI / 2.0) // 3時の位置が 0[rad]、0時の位置へ補正 val _toY = _radius * sin(_toAndle - PI / 2.0) // 文字盤の文字間の値は、ドラッグ&移動して指定する // ⇒ 文字へ吸着しないように、文字上を通過しない方向から移動 val _fromPosition = when(position % 5) { 1, 2 -> position.toDouble() + 5.0 3, 4 -> position.toDouble() - 5.0 else -> position.toDouble() } val _fromAngle = 2.0 * PI * (_fromPosition / resolution.toDouble()) val _fromX = _radius * cos(_fromAngle - PI / 2.0) // 3時の位置が 0[rad]、0時の位置へ補正 val _fromY = _radius * sin(_fromAngle - PI / 2.0) uiDev.drag( _center.x + _fromX.roundToInt(), _center.y + _fromY.roundToInt(), _center.x + _toX.roundToInt(), _center.y + _toY.roundToInt(), 20 ) uiDev.waitForIdle(2000) }
/** * ドラムライプのピッカー */ internal fun setTime24WithDrum( hour: Int, min: Int, uiDev: UiDevice = UiDevice.getInstance(InstrumentationRegistry.getInstrumentation()) ){ require(hour >= 0) {"year >= 0"} require(min >= 0) {"month >= 0"} val _current = Calendar.getInstance() val _tHour = hour % 24 val _tMin = min % 60 val _cHour = _current[Calendar.HOUR_OF_DAY] val _cMin = _current[Calendar.MINUTE] val _area = findObjByRes("android:id/timePicker", uiDev) val _aMin = _area.visibleBounds.rightOf2() val _aHour = _area.visibleBounds.leftOf2() // 時刻の指定(時、分) setFieldWithDrum(uiDev, Calendar.HOUR_OF_DAY, _aHour, _cHour, _tHour) setFieldWithDrum(uiDev, Calendar.MINUTE, _aMin, _cMin, _tMin) _area.recycle() } internal fun setTime12WithDrum( hour: Int, min: Int, uiDev: UiDevice = UiDevice.getInstance(InstrumentationRegistry.getInstrumentation()) ){ require(hour >= 0) {"year >= 0"} require(min >= 0) {"month >= 0"} val _current = Calendar.getInstance() val _tHour = hour % 12 val _tMin = min % 60 val _tAM = hour / 12 % 2 // 0:AM, 1:PM val _cHour = _current[Calendar.HOUR] val _cMin = _current[Calendar.MINUTE] val _cAM = _current[Calendar.AM_PM] val _area = findObjByRes("android:id/timePicker", uiDev) val _aAM = _area.visibleBounds.rightOf3() val _aMin = _area.visibleBounds.centerOf3() val _aHour = _area.visibleBounds.leftOf3() // 時刻の指定(AM/PM、時、分) setFieldWithDrum(uiDev, Calendar.AM_PM, _aAM, _cAM, _tAM) setFieldWithDrum(uiDev, Calendar.HOUR, _aHour, _cHour, _tHour) setFieldWithDrum(uiDev, Calendar.MINUTE, _aMin, _cMin, _tMin) _area.recycle() } private fun setFieldWithDrum( uiDev: UiDevice, field: Int, area: Rect, current: Int, target: Int ) { val _times = target - current if(_times != 0) { val _next = if(_times > 0) current + 1 else current - 1 val _cLabel = when(field) { Calendar.AM_PM -> if(current > 0) "PM" else "AM" Calendar.HOUR_OF_DAY -> "%02d".format(current) // 00,01,02,...,23 Calendar.HOUR -> if(current == 0) "12" else current.toString() // 12,0,1,..,11 Calendar.MINUTE -> "%02d".format(current) // 00,01,02,...,59 else -> current.toString() } val _nLabel = when(field) { Calendar.AM_PM -> if(target > 0) "PM" else "AM" Calendar.HOUR_OF_DAY -> "%02d".format(_next) // 00,01,02,...,23 Calendar.HOUR -> if(_next == 0) "12" else _next.toString() // 12,0,1,..,11 Calendar.MINUTE -> "%02d".format(_next) // 00,01,02,...,59 else -> current.toString() } val _toObj = findObjByText(_cLabel, area, uiDev) val _to = _toObj.visibleCenter val _fromObj = findObjByText(_nLabel, area, uiDev) val _from = _fromObj.visibleCenter swipeDrum(uiDev, _from, _to, Math.abs(_times)) _toObj.recycle() _fromObj.recycle() } }
Automaticモード
端末の時刻の制御方法に、「Automaticモード」があります。
Automaticモードは、ネットワーク(ntp)から時刻(日付を含む)を取得して、端末を正確な時刻へ自動調整する機能です。
Automaticモードの間は任意の時刻を設定することが出来ません。ですので、任意の時刻を設定したければ、Automaticモードの無効化が必要になります。
有効⇒無効
private fun _SetSystemTime(hour: Int, min: Int) { val _api = Build.VERSION.SDK_INT val _setTimeMesg = PageMesgs.getValue(_api).settime val _automaticMesg = PageMesgs.getValue(_api).auto val _okMesg = PageMesgs.getValue(_api).ok val _cancelMesg = PageMesgs.getValue(_api).cancel // 「Date & time」設定ページを開く showDateTimePage(context) // 時刻の自動(Network)設定を無効化 findObjByText(_setTimeMesg, uiDev).apply { if (! isEnabled) findObjByTextAndClick(_automaticMesg, uiDev) recycle() } // 時刻の変更 findObjByTextAndClick(_setTimeMesg, uiDev) // Pickerを開く val _picker = getTimePickerType(uiDev) Log.i(TAG, "PickerType(API${Build.VERSION.SDK_INT}) = ${_picker}") try { val _isHour24 = isHour24() Log.i(TAG, "Picker=${_picker} Hour24=${_isHour24}") when(_picker) { PickerType.DRUM -> if(_isHour24 != null && _isHour24) setTime24WithDrum(hour, min, uiDev); else setTime12WithDrum(hour, min, uiDev) PickerType.DIAL -> if(_isHour24 != null && _isHour24) setTime24WithDial(hour, min, uiDev) else setTime12WithDial(hour, min, uiDev) else -> {} } findObjByTextAndClick(_okMesg, uiDev) // OKボタン } catch (e: UiObjectNotFoundException) { findObjByResAndClick(_cancelMesg, uiDev) // CANCELボタン throw UiObjectNotFoundException("Time:${hour}:${min}") } // 「Date & time」設定ページを閉じる findObjByText(_setTimeMesg, uiDev).recycle() uiDev.pressBack() // バックボタン押下 }
無効⇒有効
private fun _SetAutomatic() { val _api = Build.VERSION.SDK_INT val _setTimeMesg = PageMesgs.getValue(_api).settime val _automaticMesg = PageMesgs.getValue(_api).auto // 「Date & time」設定ページを開く showDateTimePage(context) // 時刻の自動(Network)設定を有効化 findObjByText(_setTimeMesg, uiDev).apply { if (isEnabled) findObjByTextAndClick(_automaticMesg, uiDev) recycle() } // 「Date & time」設定ページを閉じる findObjByText(_setTimeMesg, uiDev).recycle() uiDev.pressBack() // バックボタン押下 }
Settingsページの文面の違い
APIにより、Settingsアプリの文面が異なります。マップで管理し、APIで切り替えます。
/* Settingのページで用いられるメッセージ */ internal data class MesgData( val auto: String, val setdate: String, val settime: String, val ok: String, val cancel: String ) internal val PageMesgs = mapOf( 23 to MesgData("Automatic date & time", "Set date", "Set time", "OK", "CANCEL"), 24 to MesgData("Automatic date & time", "Set date", "Set time", "OK", "CANCEL"), 25 to MesgData("Automatic date & time", "Set date", "Set time", "OK", "CANCEL"), 26 to MesgData("Automatic date & time", "Set date", "Set time", "OK", "CANCEL"), 27 to MesgData("Automatic date & time", "Set date", "Set time", "OK", "CANCEL"), 28 to MesgData("Automatic date & time", "Set date", "Set time", "OK", "CANCEL"), 29 to MesgData("Use network-provided time", "Date", "Time", "OK", "Cancel"), 30 to MesgData("Use network-provided time", "Date", "Time", "OK", "Cancel"), 31 to MesgData("Set time automatically", "Date", "Time", "OK", "Cancel"), 32 to MesgData("Set time automatically", "Date", "Time", "OK", "Cancel"), 33 to MesgData("Set time automatically", "Date", "Time", "OK", "Cancel"), 34 to MesgData("Set time automatically", "Date", "Time", "OK", "Cancel") )
「Data & TIme」ページを開く
日付の変更を行うには、GUI操作を行う前にSettingsアプリの「Data & TIme」ページを開く必要があります。
/** * Settingsアプリの「Time & Date」ページを開きます。 * * @param context コンテキスト */ internal fun showDateTimePage( context: Context = InstrumentationRegistry.getInstrumentation().context ) { val intent = Intent(Settings.ACTION_DATE_SETTINGS) intent.flags = Intent.FLAG_ACTIVITY_NEW_TASK context.startActivity(intent) }
テストルールの作成
ここまで、説明した内容を統合して、テストルールを作成します。
仕様は次の通りです。
- サポートするAPI:API≧23
- 12時制、24時制は自動判別
- 時刻の変更はテスト中のみ有効、テスト後はAutomaticモードが有効
/** * システム時刻を設定するルールを提供します. * テストが終わると自動的にAutomaticモード(Networkに経由による時刻合わせ)に変更されます. * * @param hour 時(0,1,2,...,23) ※24時制 * @param min 分 */ @TargetApi(23) class SysTimeTestRule( var hour: Int = -1, var min: Int = -1 ) : TestRule { val context = InstrumentationRegistry.getInstrumentation().context val uiDev = UiDevice.getInstance(InstrumentationRegistry.getInstrumentation()) // ----- ルール本体 ----------------------------------------------- override fun apply(base: Statement, description: Description): Statement { return object : Statement() { override fun evaluate() { try { setSysTime() // 前処理 base.evaluate() // テストの実施 } finally { setAutomatic() // 後処理 } } } } /** * システム時刻をネット経由で自動的に設定します. */ fun setAutomatic() { Log.i(TAG, "setAutomatic") _SetAutomatic() hour = -1 min = -1 } /** * システム時刻を設定します. * @param hour 時(0,1,2,...,23) ※24時制 * @param min 分 */ fun setSysTime(hour: Int = this.hour, min: Int = this.min) { Log.i(TAG, "setSysTime ${hour}:${min}") if (hour >= 0 && min >= 0) { _SetSystemTime(hour, min) this.hour = hour this.min = min } } // ---------------------------------------------------------------- private fun _SetAutomatic() { ... } private fun _SetSystemTime(hour: Int, min: Int) { ... } }
※テストルールの作り方については「JUnitのテストルールの作り方」を参照
テストルールの使用
使用方法はその他のテストルールと変わりません。@Ruleを付けてプロパティを宣言するだけです。
class Rule_SysTime_Test1 { @get:Rule var timeRule = SysTimeTestRule(11, 59) @Before fun setUp() { } @After fun tearDown() { } // ---------------------------------------------------------------- @Test fun A1_コンストラクタで時刻の指定_1159() { val _time = LocalTime.now() Truth.assertThat(_time.hour).isEqualTo(11) Truth.assertThat(_time.minute).isEqualTo(59) } }
class Rule_SysTime_Test2 { @get:Rule var timeRule = SysTimeTestRule() @Before fun setUp() { } @After fun tearDown() { } // ---------------------------------------------------------------- @Test fun B1_関数で時刻の指定_午前0916() { timeRule.setSysTime(9, 16) val _time = LocalTime.now() Truth.assertThat(_time.hour).isEqualTo(9) Truth.assertThat(_time.minute).isEqualTo(16) } }
関連記事: