「提出期限が近づいても危機感が持てない人」のためのMoodleアプリを作った時のお話

# 1. はじめに
初めまして、Punyoです。

去年末、筆者が所属する[名工大プログラミング部C0de](https://c0de-web.club.nitech.ac.jp/)にて、ピクシブ株式会社様の協賛とOBの先輩方のご協力のもと「第2回 C0deハッカソン with pixiv」が開催されました。このハッカソンへの多大なるご支援に対して、この場を借りて深く感謝申し上げます。

本記事は、筆者が関わったアプリの制作に至るまでのきっかけ、機能紹介、技術的な詳細やハッカソン全体を通した反省点に関しての記事となります。
# 2. 成果物と制作のきっかけ

今回のハッカソンでの成果物は以下の投稿の通りです。

https://x.com/c0demattari/status/1871200301501583859

筆者はこのアプリで以下の部分を担当しました。

- データレイヤすべて
- 設定画面のUI（[3-(b)参照](https://qiita.com/Punyo/items/15a8d9fb42ec37f57280#b-%E6%9C%9F%E9%99%90%E3%81%8C%E8%BF%91%E3%81%A5%E3%81%8F%E3%81%A8%E3%82%A2%E3%83%97%E3%83%AA%E3%82%92%E3%83%AD%E3%83%83%E3%82%AF%E3%81%99%E3%82%8B%E6%A9%9F%E8%83%BD)）

このアプリを制作するに至ったきっかけの一つとして、筆者がMoodle公式アプリの機能において不満を持っていたということが挙げられます。具体的には
- 今出されている課題を一目で確認できないため課題を忘れやすい
- 提出済みの課題を一目で確認できない

などです。課題に関する情報を公式アプリで確認するためには以下のようなコース一覧から教科をタップした後、その教科の全課題のリストから該当する課題を選択する必要があります。

![Screenshot_20250101_232904.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3973516/a870c6ce-04b4-be58-6e08-e754e7e89047.png)

また、筆者の高校の同期であり、筆者とは別の大学に通っている **Aくん** との以下のようなやり取りもきっかけとして挙げられます。なお、一部原文より文章を改変しています。

![script2.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3973516/d39278c1-b4af-bd03-cf05-1067cecc0ad5.png)
![script1.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3973516/9af26fd0-cc35-65eb-ee26-cf556299ba14.png)
![script3.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3973516/3c6cab54-8068-fd81-84c2-3f4b41c9e054.png)
![script4.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3973516/65b72d9f-4761-c6a7-0dcd-110b90078033.png)

彼こそがタイトルに登場する **「提出期限が近づいても危機感が持てない人」** なのです。~~Aくん、ネタにしちゃってごめんね...~~[^1]

これらの問題を解決するため、以下の機能を盛り込んだMoodleアプリの制作を決定しました。

- 未提出/提出済みの課題とそれに関する情報を一目で確認できる
- 提出期限が近づいても課題を提出していない場合指定されたアプリの使用制限を課す
- 期限までに課題を提出できなかった場合その旨をDiscordサーバーにて晒す

# 3. 機能紹介
## (a) 課題を管理する機能
大学のMoodleサーバーから情報を取得し、未提出/提出済みの課題とその期限をすぐに確認することができる機能を実装しました。
課題をタップすることで詳細が記されたBottomSheetを表示することができます。

![Screen_recording_20250101_084510-ezgif.com-video-to-gif-converter (1).gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3973516/4f1b5a8c-6dc1-b51d-7aa4-4338be1f8598.gif)

## (b) 期限が近づくとアプリをロックする機能
ユーザーが指定した時間までに課題を提出していなかった場合、指定されたアプリをロックする機能を実装しました。

![Screen_recording_20250101_090052-ezgif.com-video-to-gif-converter.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3973516/bf74f29e-575b-81fa-e8b4-e6f9a58ee98c.gif)!

ロックされているときに該当のアプリを起動しようとすると左の画像のようにこのアプリに強制的に遷移します。なお、課題提出後は右の画像のようにメッセージが表示された後ロック解除され遷移されなくなります。

![Screen_recording_20250101_094753-ezgif.com-video-to-gif-converter.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3973516/514b1f80-74d3-f8bd-acab-13bd045f66b0.gif)![Screen_recording_20250101_101920-ezgif.com-video-to-gif-converter (1).gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3973516/c583a235-4d07-360b-7b88-c01959aec33e.gif)

## (c) 期限までに課題を提出できなかった場合Discordサーバーにメッセージを送信する機能（未実装）
ユーザーが提出期限までに課題を提出できなかった場合、事前に指定したDiscordサーバーのチャンネルにメッセージを送信する機能を実装しようとしましたが、Discordのアカウントでのログイン（左）とサーバー・チャンネル選択（右）まで実装したところで時間切れになってしまいました。

![Screenshot_20250101_105003.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3973516/5053ff09-b333-edcf-d5a4-4a2d582e0c6d.png)![Screenshot_20250101_104243.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3973516/b72a625b-5c98-a572-26cf-fa62072d5645.png)

# 4. 技術的な詳細
## (a) システム全体の構成
今回作成したアプリのシステム構成は以下の通りです。

![名称未設定ファイル.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3973516/9192fb2a-88fe-0230-438e-63942a9ebc2a.png)

Ktorを用いて大学のMoodleサーバーや、Azure App Service上にNode.jsで構築されたバックエンドと通信しています。さらにそのバックエンドからDiscordのAPIを叩きBotの制御を行ったり、ユーザーが所属するサーバーのリストを取得しています。

## (b) Moodleからのデータ取得
[Moodleアプリの開発者向けドキュメント](https://moodledev.io/general/app/customisation/custom-apps)では公式Moodleアプリが叩いているAPI等に関する詳しい情報が得られなかったため、今回はプロキシサーバーの[Fiddler Classic](https://www.telerik.com/fiddler/fiddler-classic)を利用して公式アプリの通信解析を行い必要な情報を取得するためのエンドポイント等を調べました。

解析によって得られた情報と[Moodleアプリのソースコード](https://github.com/moodlehq/moodleapp)を元にMoodleサーバーとの通信をKotlinで再実装しました。（実装したコードは↓から）

https://github.com/Punyo/MoodleFunctionCaller/blob/master/app/src/main/java/com/punyo/moodlefunctioncaller/main/MoodleRemoteDataSource.kt

## (c) アプリの使用制限

アプリの起動制限機能については以下のような処理の流れになっています。

### (i) アクセシビリティサービスを有効化させる

アプリ起動時にこのアプリがアクセシビリティサービスを利用可能かのチェックを行い、不可能であった場合はユーザーに許可を促すダイアログを表示します。

この機能は後述する画面状態の変更の監視に利用します。

### (ii) 一定時間間隔でサーバーから課題データを取得する処理を開始する
設定画面（3-(c)参照）から制限開始時間が設定されると、以下の処理が実行されサーバーから一定時間間隔で課題データを取得するバックグラウンド処理`AppLimitAlarmSetterWorker`が開始されます。

```kotlin
suspend fun setAppLimitHour(context: Context, hour: Int) {
    context.dataStore.edit { preferences -> preferences[appLimitHourPreference] = hour }
    val workManager = WorkManager.getInstance(context)
    workManager.cancelAllWorkByTag(AppLimitAlarmSetterWorker.WORKER_TAG)
    workManager.enqueue(AppLimitAlarmSetterWorker.createWorkRequest())
}
```

前述した`AppLimitAlarmSetterWorker`において新しい課題データを取得した場合、以下の処理により`AlarmManager.setExactAndAllowWhileIdle()`を用いて(iii)を課題提出時間の`limitHour`時間前に実行するようにセットします。また、この処理において`Intent.putExtra()`を利用して該当課題のIDを渡しています。IDは(iii)にて提出状況の確認に利用します。
```kotlin
val intent = Intent(applicationContext, AppLimitBroadcastReceiver::class.java)
intent.putExtra(INTENT_EXTRA_APP_LIMIT_BROADCAST_RECEIVER_ASSIGNMENT_ID, assignment.id)
val pendingIntent =
    PendingIntent.getBroadcast(
        applicationContext,
        assignment.id,
        intent,
        PendingIntent.FLAG_IMMUTABLE
    )
alarmManager.setExactAndAllowWhileIdle(
    AlarmManager.RTC_WAKEUP,
    (assignment.duedate - (limitHour * 60 * 60)) * 1000,
    pendingIntent
)
```
### (iii) 課題提出時間の`limitHour`時間前に課題の提出状況の確認を行う
課題提出時間の`limitHour`時間前に課題の提出状況の確認を行い、もし課題が提出されていなかった場合はアプリのロックの有無を管理するフラグを`true`に設定します。

### (iv) 画面状態の変更を監視しているアクセシビリティサービスから画面の遷移を行う

以下のような処理により、ロックの有無を管理するフラグが`true`かつ、画面状態の変更によりロック対象のアプリが表示された時にこのアプリの`MainActivity`を起動してロック画面への遷移を行います。
```kotlin
override fun onAccessibilityEvent(event: AccessibilityEvent) {
    if (
        event.eventType == AccessibilityEvent.TYPE_WINDOW_STATE_CHANGED &&
            ::limitedAppPackageNamesList.isInitialized
    ) {
        val packageName = event.packageName.toString()
        if (limitedAppPackageNamesList.contains(packageName) && isAppLimitEnabled) {
            val result =
                CoroutineScope(Dispatchers.IO).async {
                    val appLimitRepository = AppLimitRepository(this@AppLimitService)
                    appLimitRepository.getAppLimitCauseAssignmentId()
                }
            val intent = Intent(baseContext, MainActivity::class.java)
            intent.putExtra(INTENT_EXTRA_OPEN_APP_LIMIT_SCREEN, true)
            intent.putExtra(INTENT_EXTRA_OPEN_APP_LIMIT_SCREEN_ASSIGNMENT_ID, result.getCompleted())
            intent.flags = Intent.FLAG_ACTIVITY_NEW_TASK
            startActivity(intent)
        }
    }
}
```
# 5. 反省点
最初に「この機能をこの日までに実装する」等細かい期限を決めずに開発を進めてしまったため、最終日やその一日前に徹夜して開発する羽目になり、一部機能が未完成のまま終わってしまいました。

さらに、筆者が、メンバーに割り振る仕事の量を見誤り工程が大幅に遅れてしまったことがありました。とは言っても、~~3日目に頼んだ課題確認画面のUI設計が最終日になっても終わっていないと聞いたときはさすがに困惑せざるを得ませんでしたが...~~

筆者は、今まで大まかな期限のみを定めた個人での開発を中心に行っていたため、**ハッカソンのような短い期限で一つのものを形にすることに対して慣れていなかった**ということが無計画さの原因であると考えました。
# 6. おわりに
今回が"実質"[^2]初参加かつ初めてチームのまとめ役をしたハッカソンでしたが、Discordアカウントでの認証やユーザー補助サービスの利用など、これまで実装したことのなかった機能に関しての経験を得ることができました。また、仕事の割り振りやプランニングでの教訓など、個人での開発だけでは得られない様々な学びを得ることができました。

ここでの経験や反省点を活かしつつ、これからも積極的に個人や共同での開発に邁進してまいります。

最後までお読みいただきありがとうございました！

[^1]: 彼から前もってこの記事へのやりとりの転載許可を頂いています。念のため。
[^2]: 一昨年も同じハッカソンに参加させていただきましたが、初日に開発用のPCを不注意で破壊してしまってほとんど貢献できませんでした...（すみませんでした）