毎日ひとつ「今日の壁紙」を出す機能を直していたとき、利用者から「日付が変わったのに昨日と同じ壁紙のままです」という連絡をいただきました。別の方からは逆に「夜のうちに二回入れ替わった」という報告です。同じ機能で、まったく逆の症状が同時に出ていました。
手元の Pixel で何度試しても再現しません。再現したのは、端末の日付を手で進めたときと、タイムゾーンを海外に変えたときでした。個人開発で同じアプリを長く触っていると、自分の端末がいつも同じ時刻・同じ地域にあることに慣れてしまって、時刻がずれる世界を想像しなくなります。気づいたきっかけは、AdMob のリワード広告で「一日一回」のはずの特典が、ある利用者だけ何度も付いていたログでした。時計の問題が、表示の崩れと収益の両方に同時に効いていたわけです。
最初に手を入れたコードは、Antigravity のエージェントに「今日の壁紙を日替わりで選ぶロジックを書いて」と頼んで出てきたものでした。読めば自然で、レビューでも素直に通してしまう書き方です。けれど本番の時計は、開発端末ほど行儀よく進んでくれませんでした。
エージェントが書いた「自然なコード」がどこで崩れたか
最初の実装はおおむねこうでした。エージェントの提案そのものではありませんが、要点は同じです。
// 最初の実装(本番で崩れた)
fun todaysWallpaperIndex (total: Int ): Int {
val now = System. currentTimeMillis ()
val daysSinceEpoch = now / ( 24 * 60 * 60 * 1000 ) // ミリ秒 → 日
return (daysSinceEpoch % total). toInt ()
}
一見、端末の地域に依存しない素直な計算に見えます。けれど System.currentTimeMillis() は UTC からの経過ミリ秒です。これを 24*60*60*1000 で割ると、切り替わりの瞬間は「UTC の真夜中」に固定されます。日本の利用者にとっては毎朝 9 時に壁紙が替わることになり、「日付が変わったのに替わらない」という体感につながっていました。
ここを直そうとした次の実装が、逆方向の事故を起こしました。
// 二番目の実装(別の壊れ方をした)
fun todaysWallpaperIndex (total: Int ): Int {
val cal = java.util.Calendar. getInstance () // 端末の既定タイムゾーン
val year = cal. get (java.util.Calendar.YEAR)
val day = cal. get (java.util.Calendar.DAY_OF_YEAR)
return ((year * 366 + day) % total)
}
端末のタイムゾーンを見るようになったので、日本では深夜 0 時に替わるようになりました。けれど Calendar.getInstance() は端末の現在のタイムゾーンをそのまま読みます。飛行機で時差のある地域へ移動した利用者は、移動の前後で DAY_OF_YEAR が一日進んだり戻ったりして、同じ夜に二回切り替わる、あるいは一日飛ぶ、という挙動になりました。year * 366 + day という日付キーの作り方も雑で、うるう年や年境界で破綻します。
二つの実装の失敗は、別々の原因を一つの式に詰め込んでいたことに尽きます。「いつ日付が変わるか」「どの地域の時計で測るか」「時計が巻き戻ったらどうするか」は、本来それぞれ独立した設計判断です。エージェントは目の前の一行をきれいに書いてくれますが、この三つを分けて考えるのは、アプリの性質を知っている人間の仕事だと感じました。
三つの失敗源を分けて考える
時刻まわりのバグは、たいてい次の三層のどこかから来ます。私はこの切り分けを最初に紙に書き出してから、コードに戻るようにしています。
第一に、端末時計そのもののずれです。利用者は時刻を手で変えられますし、放っておくと数分ずれている端末もあります。デイリー特典のように「進んだら得をする」境界は、ここを突かれます。
第二に、タイムゾーンです。「今日」がいつ始まるかは地域で違います。表示用の「今日」は利用者の地域で決めるのが自然ですが、その地域は移動で変わります。
第三に、夏時間です。夏時間の切り替え日には、一日が 23 時間や 25 時間になり、存在しない時刻や二度訪れる時刻が生まれます。「毎日 0 時に実行」のような素朴な前提が、年に二回だけ静かに崩れます。
この三つは、対策の方向がそれぞれ違います。表示の「今日」はタイムゾーンの問題、特典の不正は端末時計の問題、定期実行のずれは夏時間の問題、というふうに割り当てると、一つの式で全部を背負わせる無理がなくなります。
時刻ソース 保証されること 使いどころ
System.currentTimeMillis() UTC 基準・単調ではない(巻き戻る) 表示用の日付計算(ゾーンを明示する前提で)
端末のタイムゾーン設定 利用者の体感する「今日」・移動で変わる 表示の日付境界。ただし固定はしない
SystemClock.elapsedRealtime() 起動からの単調増加・手で変えられない 経過時間の判定。日付の判定には使わない
サーバー時刻 端末非依存・通信が要る 不正を厳密に防ぎたい特典の最終判定
表示の「今日」を一つの日付キーに定める
まず表示側です。毎日入れ替わる壁紙は、利用者の地域の暦の上での「今日」で替わってほしい、という素直な要件にしました。ここで大事なのは、計算のたびに端末の現在ゾーンを読みにいくのではなく、「日付キー」という一段抽象化した値を一箇所で作ることです。
import java.time.LocalDate
import java.time.ZoneId
import java.time.Clock
// 「今日」を表す安定したキー。表示・選択・キャッシュの全てがこれを参照する
data class DayKey ( val isoDate: String ) { // 例: "2026-06-25"
companion object {
fun of (clock: Clock , zone: ZoneId ): DayKey =
DayKey (LocalDate. now (clock. withZone (zone)). toString ())
}
}
// 日付キーから決定的にインデックスを引く(端末や言語に依存しない)
fun wallpaperIndexFor (key: DayKey , total: Int ): Int {
// 文字列ハッシュではなく、エポック日数で安定した連番にする
val epochDay = LocalDate. parse (key.isoDate). toEpochDay ()
return Math. floorMod (epochDay, total)
}
ポイントは三つあります。Clock を引数で受け取ること、ゾーンを明示的に渡すこと、そして「今日」を LocalDate(時刻を持たない日付)に落としてからキーにすることです。floorMod を使っているのは、% が負の値で負を返すのを避けるためで、これは過去に一度ハマった箇所でもあります。
表示用のゾーンは「端末の現在ゾーン」を既定にしつつ、利用者が設定で固定できるようにしました。旅行中に壁紙が二回替わるのを煩わしく感じる方のための逃げ道です。私はこの「既定は自動・上書きは手動」という形を、時刻に限らずよく採用しています。自動で賢くやりすぎると、ずれたときに利用者が直せなくなるからです。
巻き戻る時計から特典境界を守る
表示が直っても、AdMob のリワードやデイリーボーナスの「一日一回」は別問題です。ここは「日付が変わったら一回付与」という発想のままだと、端末時計を進めて何度でも受け取れてしまいます。実際にログで見えていたのは、まさにこの経路でした。リワードの二重付与は、その週の全付与のうち約4%を占めていて、収益というより信頼の問題として無視できない比率でした。
対策は、最後に付与した日付キーを単調増加でしか進めない、という不変条件を一つ持つことでした。
class DailyRewardGate (
private val store: PreferenceStore ,
private val clock: Clock ,
private val zone: ZoneId ,
) {
// 付与できる場合のみ true を返し、内部状態を進める
fun tryClaim (): Boolean {
val today = DayKey. of (clock, zone).isoDate
val lastClaimed = store. getString ( "last_reward_day" ) // "2026-06-24" 等
// 端末時計が巻き戻された場合: today < lastClaimed のときは付与しない
if (lastClaimed != null && today <= lastClaimed) return false
// 同時タップでの二重付与を防ぐため、書き込みは比較と同じトランザクションで
return store. compareAndSet ( "last_reward_day" , expected = lastClaimed, newValue = today)
}
}
today <= lastClaimed のときに付与しないのが肝です。前に進んだ分だけ得をする攻撃も、巻き戻して同じ日をやり直す攻撃も、文字列としての日付比較で同時に塞げます。compareAndSet を挟んでいるのは、広告完了のコールバックが二回飛ぶ端末が現実にあり、素朴な「読んで書く」では二重付与が残るからです。
厳密さがさらに要るなら、最終判定はサーバー時刻に寄せることを私は推奨します。私の個人開発の規模では、巻き戻し防御をクライアントに置きつつ、月一回の精算だけサーバーで突き合わせる、という濃淡のつけ方で十分でした。全部をサーバーにすると、オフラインで壁紙すら出せなくなる方が損です。どこまで守るかは、不正の被害額と通信の前提で決める判断だと考えています。
夏時間と「毎日 0 時」の定期実行
三つ目は定期実行です。壁紙の事前ダウンロードや通知を「毎日 0 時」に走らせていたのですが、夏時間の切り替え日には、その 0 時が存在しなかったり二度来たりします。WorkManager のような仕組みに「24 時間ごと」と頼むのも、起点がずれると累積でずれていきます。
私が落ち着いたのは、固定間隔ではなく「次の日付境界まで」を都度計算して予約し直す形です。
import java.time.ZonedDateTime
import java.time.Duration
// 次の地域日付の始まり(0時)までの待ち時間を、夏時間込みで求める
fun durationUntilNextLocalMidnight (clock: Clock , zone: ZoneId ): Duration {
val now = ZonedDateTime. now (clock. withZone (zone))
val nextMidnight = now. toLocalDate (). plusDays ( 1 ). atStartOfDay (zone)
return Duration. between (now, nextMidnight)
}
atStartOfDay(zone) は、夏時間で 0 時が飛ぶ地域でも「その日の始まりとして妥当な瞬間」を返してくれます。java.time が地域ルールを持っているので、ここは自前で時差を足し引きしないのが正解でした。逆に、ミリ秒や時間を手で足して「次の 0 時」を出そうとすると、年に二回だけ一時間ずれます。エージェントに「24 時間後に再実行して」と素直に書かせると、たいていこの落とし穴に入ります。
注入できる時計でテストする
ここまでの設計の見返りは、テストのしやすさに出ました。Clock を引数で受け取る形にしておくと、夏時間の前後や日付の変わり目を、実機の時計を触らずに再現できます。
import java.time.Instant
import java.time.ZoneId
import kotlin.test.Test
import kotlin.test.assertFalse
import kotlin.test.assertTrue
class DailyRewardGateTest {
private val tokyo = ZoneId. of ( "Asia/Tokyo" )
@Test fun 巻き戻し時計では二回目を付与しない () {
var instant = Instant. parse ( "2026-06-25T15:30:00Z" ) // JST 6/26 0:30
val clock = object : java .time. Clock () {
override fun instant () = instant
override fun getZone () = tokyo
override fun withZone (z: ZoneId ) = this
}
val gate = DailyRewardGate ( InMemoryStore (), clock, tokyo)
assertTrue (gate. tryClaim ()) // 6/26 分を付与
instant = Instant. parse ( "2026-06-24T15:30:00Z" ) // 時計を巻き戻す
assertFalse (gate. tryClaim ()) // 過去日では付与されない
}
}
このテストを書いてから、私は時刻まわりの修正に対する怖さがかなり減りました。ZoneId.of("America/New_York") に差し替えれば夏時間の切り替え日も再現できますし、エージェントに「このゲートの境界条件のテストを追加して」と頼んでも、注入できる時計があるおかげで的外れな実機依存テストになりません。計測やテストの足場づくりはエージェントに任せ、どの境界を守るかという不変条件は自分で決める。時刻処理では、この線引きがとくに効くと感じています。
修正をリリースしたあと、「日付が変わったのに替わらない」「夜に二回替わった」という同種の問い合わせは、四週間で十数件あったものがゼロになりました。リワードの二重付与もログから消えました。派手な機能ではありませんが、本番運用に入れてからの数週間で、毎日触れる場所の小さな違和感が消えることは、長く使ってもらううえで地味に効きます。
同じように「今日」を扱う機能を持っている方は、まず自分のコードのどこが端末時計・タイムゾーン・夏時間のどれに依存しているかを一度紙に書き出してみてください。三つを分けた瞬間に、直すべき一行がはっきり見えてきます。