banner
MuElnova

NoxA

Becoming a random anime guy... Pwner@天枢Dubhe/天璇Merak | BUPT CyberSecurity | INTP-A
email
github
bilibili
steam
twitter

小米バンド 8 Pro が Obsidian にデータを自動アップロードする方法

数日前、DIYGOD を参考にして生活管理システムを作成しました。さまざまなプラグインのおかげで半自動化が実現しましたが、睡眠時間や歩数、心拍数や血圧などのデータは手動で記録しなければならず、あまり Geek とは言えません。調べたところ、実は Zepp(元 Huami)には逆向きの API インターフェースが存在し、歩数などの情報が平文で保存されていることがわかりました。そこで、思い切って 小米手环 8 Pro 原神コラボ版 を購入しました。手に入れてみると、小米手环 8 はすでに Zepp をサポートしていないことに驚きました。小米手环 7 は表面上サポートしていないものの、QR コードを変更して Zepp のインストールパッケージを使う方法がありましたが、小米手环 8 は完全に Zepp を廃止してしまいました。

初探 —— パケットキャプチャ#

まずは、パケットキャプチャに有用な情報があるかどうかを確認します。以前は proxifier を使ってパケットキャプチャを行っていましたが、あまり効果がありませんでした。その理由は、一部のソフトウェアが SSL ピンニングを行っているためです。そこで、今回は mitmproxy + システムレベルの証明書を使用する方法を採用しました。

ツールチェーン#

テスト方法#

長話を短くすると、まず PC に mitmproxy をインストールし、次に $HOME/.mitmproxy ディレクトリ内の mitmproxy-ca-cert.cer ファイルを取得し、通常のワークフローに従って Android デバイスにインストールします。

私のケースでは、検索で cred に関連する用語を探し、Credential storage を見つけ、Install certificates from storage が表示されました。これが私の通常のワークフローです。デバイスによっては異なるワークフローがあるかもしれません。

Magisk に ConscryptTrustUserCerts をインストールし、再起動すると、ブート段階でユーザーレベルの証明書がシステムレベルの証明書ディレクトリにマウントされ、準備が整いました。

PC で mitmweb を開き、スマートフォンの Wi-Fi 設定をプロキシ <my-pc-ip>:8080 に設定し、テストして HTTPS リクエストを正常にキャプチャしました。

結論#

あまり役に立ちませんでした。すべてのリクエストは暗号化されており、セキュリティを確保するために署名やハッシュ、ノンスなどもあります。APK を逆向きにする気にはなれず、断念しました。

窺見光明 —— BLE 接続#

パケットキャプチャがうまくいかないので、直接 BLE クライアントを作成して手環に接続し、データを取得することにしました。これは明らかに非常に合理的な方法です。また、この方法では私のスマートフォンで何か操作をする必要もなく、Obsidian がスクリプトを実行し、接続してデータを取得するだけで、非常に自動化されているように思えます。

実装#

コードは主に wuhan005/mebeats: 💓 小米手环リアルタイム心拍数データ収集 - Your Soul, Your Beats! を参考にしました。ただし、彼のツールチェーンは MacOS で、私は持っていなかったので、GPT に尋ねて修正しました。

コード内に auth_key というものがあり、公式アプリから取得する必要があります。直接 このサイト を使って取得することもできますが、第三者を信用しないという原則に従い、手動で取得することにしました。
混乱が生じ、元のデータベースには存在しなくなりました。さらに、BLE は同時に一つのデバイスにしか接続できないことに気づき、公式アプリの優先度が明らかに高いため、断念しました。

逆向きにした後、前の部分に戻って少し書きます。

public final void bindDeviceToServer(lg1 lg1Var) {
  
        Logger.i(getTAG(), "bindDeviceToServer start");
  
        HuaMiInternalApiCaller huaMiDevice = HuaMiDeviceTool.Companion.getInstance().getHuaMiDevice(this.mac);
  
        if (huaMiDevice == null) {
  
            String tag = getTAG();
  
            Logger.i(tag + "bindDeviceToServer huaMiDevice == null", new Object[0]);
  
            if (lg1Var != null) {
  
                lg1Var.onConnectFailure(4);
  
            }
  
        } else if (needCheckLockRegion() && isParallel(huaMiDevice)) {
  
            unbindHuaMiDevice(huaMiDevice, lg1Var);
  
        } else {
  
            DeviceInfoExt deviceInfo = huaMiDevice.getDeviceInfo();
  
            if (deviceInfo == null) {
  
                String tag2 = getTAG();
  
                Logger.i(tag2 + "bindDeviceToServer deviceInfo == null", new Object[0]);
  
                return;
  
            }
  
            String sn = deviceInfo.getSn();
  
            setMDid("huami." + sn);
  
            setSn(deviceInfo.getSn());
  
            BindRequestData create = BindRequestData.Companion.create(deviceInfo.getSn(), this.mac, deviceInfo.getDeviceId(), deviceInfo.getDeviceType(), deviceInfo.getDeviceSource(), deviceInfo.getAuthKey(), deviceInfo.getFirmwareVersion(), deviceInfo.getSoftwareVersion(), deviceInfo.getSystemVersion(), deviceInfo.getSystemModel(), deviceInfo.getHardwareVersion());
  
            String tag3 = getTAG();
  
            Logger.d(tag3 + create, new Object[0]);
  
            getMHuaMiRequest().bindDevice(create, new HuaMiDeviceBinder$bindDeviceToServer$1(this, lg1Var), new HuaMiDeviceBinder$bindDeviceToServer$2(lg1Var, this));
  
        }
  
    }

deviceInfo から取得されていることがわかりますが、それは huamiDevice から来ています。少し遡ると、これは MAC から計算されたものであることがわかりますが、具体的な内容は見ませんでした。興味のある方は com.xiaomi.wearable.wear.connection というパッケージを見てください。

大道至簡 —— Frida Hook#

ここまで来ると、最終的なアイデアが決まりました。逆向きにしてしまおうと。最終的に送信されるのは暗号化されているので、暗号化されていないデータ処理のプロセスが必ず存在します。逆向きにしてフックし、XPosed プラグインを作成して監視すれば良いのです。
ここでは、時間が遅くなったため、frida のインストール方法を書くのは避けます。

まず jadx-gui には copy as frida snippets の機能があり、かなりの手間を省けます。しかし、kotlin のデータクラスのさまざまな奇妙な理由により、多くの場合、取得できませんでした。私は問題を記録しながら進めなかったので、プロセスを大まかに振り返ります:

  1. まず、/data/data/com.mi.health/databases フォルダーでユーザーに対応するフォルダーを見つけ、その中に fitness_summary というデータベースがあり、読み取ると必要なデータが存在しました。したがって、初期の検索キーワード fitness_summary を使用して交差参照し、com.xiaomi.fit.fitness.persist.db.internal というクラスに遡りました。
  2. updateinsert などの関数を見つけ、試行を続けましたが、出力を見ることはできませんでした。しかし、最終的に com.xiaomi.fit.fitness.persist.db.internal.h.getDailyRecord という関数を見つけ、毎回リフレッシュ時に出力が得られましたが、sidtime などの値しか含まれていませんでした。
  3. さらに遡り、以下のコードスニペットを使用してオーバーロードやパラメータータイプを確認しました。
var insertMethodOverloads = hClass.updateAll.overloads;

for (var i = 0; i < insertMethodOverloads.length; i++) {
	var overload = insertMethodOverloads[i];
	console.log("Overload #" + i + " has " + overload.argumentTypes.length + " arguments.");
	for (var j = 0; j < overload.argumentTypes.length; j++) {
		console.log(" - Argument " + j + ": " + overload.argumentTypes[j].className);
	}
}
  1. 突然、例外を利用して関数呼び出しスタックを確認できることに気づき、まさに雲が晴れて月が見えた瞬間でした。
var callerMethodName = Java.use("android.util.Log").getStackTraceString(Java.use("java.lang.Exception").$new());
console.log("getTheOneDailyRecord called by: " + callerMethodName);
  1. 一層一層遡り、com.xiaomi.fit.fitness.export.data.aggregation.DailyBasicReport というクラスを見つけ、私のニーズを完璧に満たしました。
    dbutilsClass.getAllDailyRecord.overload('com.xiaomi.fit.fitness.export.data.annotation.HomeDataType', 'java.lang.String', 'long', 'long', 'int').implementation = function (homeDataType, str, j, j2, i) {
        console.log("getAllDailyRecord called with args: " + homeDataType + ", " + str + ", " + j + ", " + j2 + ", " + i);
        var result = this.getAllDailyRecord(homeDataType, str, j, j2, i);
        var entrySet = result.entrySet();
        var iterator = entrySet.iterator();
        while (iterator.hasNext()) {
            var entry = iterator.next();
            console.log("entry: " + entry);
        }
        var callerMethodName = Java.use("android.util.Log").getStackTraceString(Java.use("java.lang.Exception").$new());
        console.log("getTheOneDailyRecord called by: " + callerMethodName);
        return result; 
    }
// DailyStepReport(time=1706745600, time = 2024-02-01 08:00:00, tag='days', steps=110, distance=66, calories=3, minStartTime=1706809500, maxEndTime=1706809560, avgStep=110, avgDis=66, active=[], stepRecords=[StepRecord{time = 2024-02-02 01:30:00, steps = 110, distance = 66, calories = 3}])
  1. 難しい問題に直面しました。なぜなら、この stepsprivate 属性であり、jadx-gui では複数のインターフェース getSteps()getSourceData() が表示されましたが、どれも not a function と表示されました。これは Kotlin と Java の処理方法の違いによるものだと推測しました。最終的にはリフレクションを使用して解決しました。
    これにより、最終的な frida コードは以下のようになり、その日の steps データを取得でき、HomeDataType を変更することで他のデータも取得できます。
var CommonSummaryUpdaterCompanion = Java.use("com.xiaomi.fitness.aggregation.health.updater.CommonSummaryUpdater$Companion");
var HomeDataType = Java.use("com.xiaomi.fit.fitness.export.data.annotation.HomeDataType");
var instance = CommonSummaryUpdaterCompanion.$new().getInstance();
console.log("instance: " + instance);

var step = HomeDataType.STEP;
var DailyStepReport = Java.use("com.xiaomi.fit.fitness.export.data.aggregation.DailyStepReport");

var result = instance.getReportList(step.value, 1706745600, 1706832000);
var report = result.get(0);
console.log("report: " + report + report.getClass());


var stepsField = DailyStepReport.class.getDeclaredField("steps");
stepsField.setAccessible(true);
var steps = stepsField.get(report);
console.log("Steps: " + steps);
// Steps: 110

最終 —— XPosed プラグイン#

現在のアイデアは、XPosed で特定のアドレスを監視し、少しの保護を施して明文送信を防ぐことを考えています。なぜなら、このアプリは常に起動しているため、実行可能だと思います。今の問題は、Kotlin を書けないこと、さらに XPosed も書けないことです。

幸い、Kotlin のコンパイラのヒントは非常に強力であり、XPosed 自体は設定の構築以外に特別な知識を必要としないため、強力な GPT を活用して一、二時間で基本的な環境を整えることができました(Gradle の評価が難しく、プロキシを開かないと遅く、開くとダウンロードできない)。

環境構築#

とにかく、Android Studio で No Activity のプロジェクトを直接作成します。誰も Gradle Kotlin が XPosed のためにどう設定されているかを書いていないので、ここで簡単に説明します。主にネット上では直接 settings.gradle を使っているものが多く、かなり古いものです。

// settings.gradle.kts
dependencyResolutionManagement {
    repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
    repositories {
        google()
        mavenCentral()
        maven { url = uri("https://api.xposed.info/") }
    }
}
// build.gradle.kts
dependencies {
    compileOnly("de.robv.android.xposed:api:82")  // この行
    implementation("androidx.core:core-ktx:1.10.1")
    implementation("androidx.appcompat:appcompat:1.6.1")
    implementation("com.google.android.material:material:1.9.0")
    testImplementation("junit:junit:4.13.2")
    androidTestImplementation("androidx.test.ext:junit:1.1.5")
    androidTestImplementation("androidx.test.espresso:espresso-core:3.5.1")
    implementation(kotlin("reflect"))
    implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.6.2")
}
<!-- AndroidManifest.xml、主に以下のメタデータ -->
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:tools="http://schemas.android.com/tools">

    <application
        android:allowBackup="true"
        android:dataExtractionRules="@xml/data_extraction_rules"
        android:fullBackupContent="@xml/backup_rules"
        android:icon="@mipmap/ic_launcher"
        android:label="@string/app_name"
        android:roundIcon="@mipmap/ic_launcher_round"
        android:supportsRtl="true"
        android:theme="@style/Theme.MiBandUploader"
        tools:targetApi="31" >

        <meta-data
            android:name="xposedmodule"
            android:value="true" />
        <meta-data
            android:name="xposeddescription"
            android:value="Mi Fitness Data Uploader" />
        <meta-data
            android:name="xposedminversion"
            android:value="53" />
        <meta-data
            android:name="xposedscope"
            android:resource="@array/xposedscope" />
    </application>

</manifest>
<!-- res/values/array.xml、上記の xposedscope に対応する、すなわち作用域パッケージ名 -->
<?xml version="1.0" encoding="utf-8"?>
<resources>
    <string-array name="xposedscope" >
        <item>com.mi.health</item>
    </string-array>
</resources>

その後、app/src/main/ の下に新しい assets/xposed_init ファイルを作成し、内容にエントリクラスを記入します。

sh.ouo.miband.uploader.MainHook

これで、コンパイルして LSPosed Manager でプラグインを確認できるようになります。

アイデア#

HOOK ポイント#

私たちは考えます。バックグラウンドで起動する必要があるので、小米健康自体にはいくつかの保活と自動起動のメカニズムがあるため、MainActivity の onCreate メソッドをフックする必要はなく、自動起動のメソッドを探せば良いのです。

Android の自動起動メソッドを少し検索したところ、BOOT_COMPLETED ブロードキャストリスナー、AlarmManager 定期タスク、JobScheduler 作業、Service などが考えられます。jadx-gui で検索したところ、com.xiaomi.fitness.keep_alive.KeepAliveHelper というクラスの startService メソッドを見つけました。テストの結果、確かに使用できることが確認されました。

ここでは主にシングルトンを利用して、重複登録を避けます。主な関数は handleLoadPackage で、対応する LoadPackageParam を取得し、フックしたい関数に対して XC_MethodHook を継承すれば良いのです。

以下は、CommonSummaryUpdater のインスタンスを取得し、先ほどの frida と連携させるためのコードです。

import android.util.Log
import de.robv.android.xposed.IXposedHookLoadPackage
import de.robv.android.xposed.XC_MethodHook
import de.robv.android.xposed.XposedHelpers
import de.robv.android.xposed.callbacks.XC_LoadPackage


class MainHook : IXposedHookLoadPackage {
    companion object {
        @Volatile
        var isReceiverRegistered = false
    }

    override fun handleLoadPackage(lpparam: XC_LoadPackage.LoadPackageParam) {
        if (lpparam.packageName != "com.mi.health") return
        hook(lpparam)
    }

    private fun hook(lpparam: XC_LoadPackage.LoadPackageParam) {
        XposedHelpers.findAndHookMethod(
            "com.xiaomi.fitness.keep_alive.KeepAliveHelper",
            lpparam.classLoader,
            "startService",
            object : XC_MethodHook() {
                @Throws(Throwable::class)
                override fun afterHookedMethod(param: MethodHookParam) {
                    if ( !isReceiverRegistered ) {
                        Log.d("MiBand", "MiUploader Hook Startup...")
                        val updaterClass = XposedHelpers.findClass("com.xiaomi.fitness.aggregation.health.updater.CommonSummaryUpdater", lpparam.classLoader)
                        val companionInstance = XposedHelpers.getStaticObjectField(updaterClass, "Companion")
                        val commonSummaryUpdaterInstance = XposedHelpers.callMethod(companionInstance, "getInstance")
                        Log.d("MiBand","MiUploader Receiver Deployed!")
                        isReceiverRegistered = true
                    }
                    super.afterHookedMethod(param)
                }
            })
    }
}

データ抽出#

基本的には frida と似ており、対応するメソッドを呼び出して解析するだけです。ここで、少し抽象的な基底クラスを書きましたが、これが本当に必要かどうかはわかりません。

import android.util.Log
import de.robv.android.xposed.XposedHelpers
import de.robv.android.xposed.callbacks.XC_LoadPackage.LoadPackageParam
import kotlinx.serialization.json.JsonElement
import java.time.LocalDate
import java.time.ZoneId
import java.time.format.DateTimeFormatter

abstract class DailyReportBase (
    protected val lpparam: LoadPackageParam,
    private val instance: Any
) {
    private lateinit var enumValue: Any

    protected fun setEnumValue(type: String) {
        val homeDataType = XposedHelpers.findClass("com.xiaomi.fit.fitness.export.data.annotation.HomeDataType", lpparam.classLoader)
        enumValue = XposedHelpers.getStaticObjectField(homeDataType, type)
    }

    private fun getDay(day: String?): Pair<Long, Long> {
        val formatPattern = DateTimeFormatter.ofPattern("yyyy-MM-dd")
        val beijingZoneId = ZoneId.of("Asia/Shanghai")
        val today = if (day == null) {
            LocalDate.now(beijingZoneId)
        } else {
            LocalDate.parse(day, formatPattern)
        }
        val startOfDay = today.atStartOfDay(beijingZoneId)
        Log.d("MiBand", startOfDay.toString())
        val startOfDayTimestamp = startOfDay.toEpochSecond()
        val endOfDayTimestamp = startOfDay.plusDays(1).minusSeconds(1).toEpochSecond() // 1秒引いてその日の終了時間を取得
        return Pair(startOfDayTimestamp, endOfDayTimestamp)
    }

    fun getDailyReport(day: String?): JsonElement {
        val (j1, j2) = getDay(day)
        Log.d("MiBand", "Ready to call: $instance, $enumValue, $j1, $j2")
        val result = XposedHelpers.callMethod(
            instance,
            "getReportList",
            enumValue,
            j1,
            j2
        ) as ArrayList<*>
        return toJson(result)
    }

    abstract fun toJson(obj: ArrayList<*>): JsonElement
}

Kotlin を知らないため、書き方が非常に奇妙ですが、基本的なアイデアは各サブクラスが setEnumValue を呼び出して getDailyReport の列挙値を設定し、toJson をオーバーライドすれば良いというものです。

ここでの JSON では多くの問題に直面しました。主にその型注釈が原因で、非常に崩れやすいです。

ステップレポートの例を見てみましょう。

import android.util.Log
import de.robv.android.xposed.XposedHelpers
import de.robv.android.xposed.callbacks.XC_LoadPackage
import kotlinx.serialization.Serializable
import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonElement

class StepDailyReport(lpparam: XC_LoadPackage.LoadPackageParam,
                      instance: Any
) : DailyReportBase(lpparam, instance) {
    init {
        setEnumValue("STEP")
    }

    override fun toJson(obj: ArrayList<*>): JsonElement {
        Log.d("MiBand", obj.toString())
        val today = obj.getOrNull(0)
        if (today != null) {
            try {
                return // 何を書く?
            }
            catch (e: Exception) {
                throw e
            }
        }
        throw NoSuchFieldException("データが取得できませんでした")
    }
}

さて、問題が発生しました。取得した todaycom.xiaomi.fit.fitness.export.data.aggregation.DailyStepReport のインスタンスですが、これを JSON にシリアライズするにはどうすればよいのでしょうか?型注釈には Any と書くしかなく、どのようなオブジェクトがあるかコンパイラにはわかりません。シリアライズする方法もわかりません。さらに、オブジェクトのネストもあります。

とにかく、テストを繰り返し、多くの検索を行いましたが、直接的な方法は見つかりませんでした。誰か助けてくれる方がいればと思います。かなりの時間をかけて、最終的には中間データクラスを自分で作成することに決めました。

    @Serializable
    data class SerializableDailyStepReport(
        val time: Long,
        val tag: String,
        val steps: Int,
        val distance: Int,
        val calories: Int,
        val minStartTime: Long?,
        val maxEndTime: Long?,
        val avgStep: Int,
        val avgDis: Int,
        val stepRecords: List<SerializableStepRecord>,
        val activeStageList: List<SerializableActiveStageItem>
    )

    @Serializable
     data class SerializableStepRecord(
        val time: Long,
        val steps: Int,
        val distance: Int,
        val calories: Int
    )

    @Serializable
    data class SerializableActiveStageItem(
        val calories: Int,
        val distance: Int,
        val endTime: Long,
        val riseHeight: Float?,
        val startTime: Long,
        val steps: Int?,
        val type: Int
    )

    private fun convertToSerializableReport(xposedReport: Any): SerializableDailyStepReport {
        val stepRecordsObject = XposedHelpers.getObjectField(xposedReport, "stepRecords") as List<*>
        val activeStageListObject = XposedHelpers.getObjectField(xposedReport, "activeStageList") as List<*>

        val stepRecords = stepRecordsObject.mapNotNull { record ->
            if (record != null) {
                SerializableStepRecord(
                    time = XposedHelpers.getLongField(record, "time"),
                    steps = XposedHelpers.getIntField(record, "steps"),
                    distance = XposedHelpers.getIntField(record, "distance"),
                    calories = XposedHelpers.getIntField(record, "calories")
                )
            } else null
        }

        val activeStageList = activeStageListObject.mapNotNull { activeStageItem ->
            if (activeStageItem != null) {
                SerializableActiveStageItem(
                    calories = XposedHelpers.getIntField(activeStageItem, "calories"),
                    distance = XposedHelpers.getIntField(activeStageItem, "distance"),
                    endTime = XposedHelpers.getLongField(activeStageItem, "endTime"),
                    riseHeight = XposedHelpers.getObjectField(activeStageItem, "riseHeight") as? Float,
                    startTime = XposedHelpers.getLongField(activeStageItem, "startTime"),
                    steps = XposedHelpers.getObjectField(activeStageItem, "steps") as? Int,
                    type = XposedHelpers.getIntField(activeStageItem, "type")
                )
            } else null
        }

        return SerializableDailyStepReport(
            time = XposedHelpers.getLongField(xposedReport, "time"),
            tag = XposedHelpers.getObjectField(xposedReport, "tag") as String,
            steps = XposedHelpers.getIntField(xposedReport, "steps"),
            distance = XposedHelpers.getIntField(xposedReport, "distance"),
            calories = XposedHelpers.getIntField(xposedReport, "calories"),
            minStartTime = XposedHelpers.getObjectField(xposedReport, "minStartTime") as Long?,
            maxEndTime = XposedHelpers.getObjectField(xposedReport, "maxEndTime") as Long?,
            avgStep = XposedHelpers.callMethod(xposedReport, "getAvgStepsPerDay") as Int,
            avgDis = XposedHelpers.callMethod(xposedReport, "getAvgDistancePerDay") as Int,
            stepRecords = stepRecords,
            activeStageList = activeStageList
        )
    }
}

とにかく、見た目は悪いですが、効率はおそらく低いでしょうが、私はどうすることもできませんでした。serialization ライブラリを利用しました。

// build.gradle.kts [Module]
plugins {
    ...
    kotlin("plugin.serialization") version "1.9.21"
}

dependencies {
    ...
    implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.6.2")
}

戻る場所では、String を返すこともあれば、Json を返すこともあるため、JsonElement を使用しましたが、型注釈のため、次のように書かなければなりませんでした(少なくとも私は GPT にそう言われました)。

return Json.encodeToJsonElement(SerializableDailyStepReport.serializer(), convertToSerializableReport(today))

監視#

ここで私は本当に混乱しました。最初は BroadcastReceiver を使用しようと考えましたが、これにはいくつかの考慮事項が伴います:

  1. コンピュータはどのように Android にブロードキャストを送信するのか?

    adb を使用して、adb shell am broadcast -a ACTION --es "extra_key" "extra_value" を実行します。しかし、テストの結果、Android 11 以降、adb 無線デバッグのポートが変更されることがわかりました(以前は固定の 5555 でした)。Wi-Fi を変更したり、Wi-Fi を切断したりすると、開発者設定で無線デバッグを再度有効にする必要があります。

    方法はあります。adb shellsetprop <key> <value> を実行し、以下の値を変更すれば、これらの値を永続的に変更できます。

    service.adb.tls.port=38420
    service.adb.tcp.port=38420
    
    persist.adb.tls_server.enable=1
    

    ただし、現在の /system ディレクトリは書き込み不可です。つまり、build.prop を編集してこれらの値を永続的に変更することはできません。再起動すると元に戻ってしまいます。これは明らかに面倒です(私は通常シャットダウンしないので)。

    もちろん方法はあります。Magisk モジュールを作成し、起動時に設定すれば良いのです(笑)。

  2. ブロードキャストは一方向通信であり、コンピュータはどのようにメッセージを受信するのか?

    良い方法は思いつきませんでした。現在の考えは、直接ファイルに書き込み、コンピュータ側で adb pull して読み取ることです。

したがって、これを放棄し、HTTP Restful API に移行しました。Ktor を利用してすぐに実装しました(GPT を利用)。

image-20240203140011022

しかし、この時点で別の問題が発生しました。このデータの取得頻度は非常に低いですが、時間が不規則であるという特徴があります。したがって、安定性のために HTTP サーバーを常に開いておく必要がありますが、HTTP サーバーは維持するものが非常に多いため、電力消費が非常に大きいです(テストはしていませんが)。

したがって、ソケットの抱擁に戻ることにしました。結局、ほとんど同じです。

class MySocketServer(
    private val port: Int,
    private val lpparam: LoadPackageParam,
    private val instance: Any
    ) {
    fun startServerInBackground() {
        Thread {
            try {
                val serverSocket = ServerSocket(port)
                Log.d("MiBand", "Server started on port: ${serverSocket.localPort}")
                while (!Thread.currentThread().isInterrupted) {
                    val clientSocket = serverSocket.accept()
                    val clientHandler = ClientHandler(clientSocket)
                    Thread(clientHandler).start()
                }
            } catch (e: Exception) {
                Log.e("MiBand", "Server Error: ${e.message}")
            }
        }.start()
    }

その後、ある困難な問題に気づきました。Obsidian で Templater を使用して毎日の情報を取得する必要があるため、JavaScript を使用しますが、Obsidian はサンドボックスのような環境であるため、外部スクリプトを実行することはできません。JavaScript ではソケットを使用できません。さて、HTTP プロトコルを手作りすることにしました。安全性はさておき、評価は使えるかどうかです。

override fun run() {
            try {
                Log.d("MiBand", "Connection: $clientSocket")
                val inputStream = BufferedReader(InputStreamReader(clientSocket.getInputStream()))
                val outputStream = PrintWriter(clientSocket.getOutputStream(), true)

                // HTTP リクエストの最初の行を読み取る
                val requestLine = inputStream.readLine()
                println("Received: $requestLine")

                // リクエスト行を解析する
                val requestParts = requestLine?.split(" ")
                if (requestParts == null || requestParts.size < 3 || requestParts[0] != "GET") {
                    val resp = SerializableResponse(
                        status = 1,
                        data = JsonPrimitive("無効なリクエスト")
                    )
                    sendSuccessResponse(outputStream, resp)
                    return
                }

                val pathWithParams = requestParts[1]
                val path = pathWithParams.split("?")[0]
                val params = parseQueryString(pathWithParams.split("?").getOrNull(1))

                when (path) {
                    "/getDailyReport" -> {
                        val type = params["type"]
                        val date = params["date"]
                        if (type == null) {
                            val resp = SerializableResponse(
                                status = 1,
                                data = JsonPrimitive("'/getDailyReport' の 'type' パラメータが欠落しています")
                            )
                            sendSuccessResponse(outputStream, resp)
                        } else {
                            // getDailyReport リクエストを処理する
                            var resp: SerializableResponse
                            try {
                                val report = DailyReportFactory.createDailyReport(lpparam, instance, type)
                                val result = report.getDailyReport(date)
                                resp = SerializableResponse(
                                    status = 0,
                                    data = result
                                )

                            }
                            catch (e: Exception) {
                                resp = SerializableResponse(
                                    status = 1,
                                    data = JsonPrimitive(e.message)
                                )
                            }
                            sendSuccessResponse(outputStream, resp)

                        }
                    }
                    else -> {
                        val resp = SerializableResponse(
                            status = 1,
                            data = JsonPrimitive("未知のパス: $path")
                        )
                        sendSuccessResponse(outputStream, resp)
                    }
                }
                inputStream.close()
                outputStream.close()
                clientSocket.close()
                Log.d("MiBand", "接続完了")
            } catch (e: IOException) {
                e.printStackTrace()
            }
        }
    }

    private fun parseQueryString(query: String?): Map<String, String> {
        val queryPairs = LinkedHashMap<String, String>()
        val pairs = query?.split("&") ?: emptyList()
        for (pair in pairs) {
            val idx = pair.indexOf("=")
            if (idx != -1) {
                val key = pair.substring(0, idx)
                val value = pair.substring(idx + 1)
                queryPairs[key] = value
            }
        }
        return queryPairs
    }
    private fun sendSuccessResponse(outputStream: PrintWriter, result: SerializableResponse) {
        val jsonResponse = Json.encodeToString(result)
        val response = """
            HTTP/1.1 200 OK
            Content-Type: application/json
            Connection: close
            Content-Length: ${jsonResponse.toByteArray().size}
            
            $jsonResponse
        """.trimIndent()
        outputStream.println(response)
        outputStream.flush()
    }

非常健康な睡眠状態

ソースコードは後でアップロードしますが、今は純粋な半完成品です。評価は私の睡眠データを盗むだけです。

読み込み中...
文章は、創作者によって署名され、ブロックチェーンに安全に保存されています。