feat(profile): support age secret keys (#764)

This commit is contained in:
LinBeitsi 2026-06-04 21:00:32 +08:00 committed by GitHub
parent 7f63b750f2
commit a69dd69aa7
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
29 changed files with 187 additions and 72 deletions

View File

@ -47,7 +47,7 @@ class ExternalControlActivity : Activity(), CoroutineScope by MainScope() {
val intervalMs = java.util.concurrent.TimeUnit.MINUTES.toMillis(updateInterval)
create(type, name).also {
patch(it, name, url, intervalMs)
patch(it, name, url, intervalMs, null)
}
}
startActivity(PropertiesActivity::class.intent.setUUID(uuid))
@ -103,4 +103,4 @@ class ExternalControlActivity : Activity(), CoroutineScope by MainScope() {
@Suppress("DEPRECATION")
overridePendingTransition(0, 0)
}
}
}

View File

@ -45,7 +45,7 @@ class PropertiesActivity : BaseActivity<PropertiesDesign>() {
if (!canceled && profile != original) {
withProfile {
patch(profile.uuid, profile.name, profile.source, profile.interval)
patch(profile.uuid, profile.name, profile.source, profile.interval, profile.ageSecretKey)
}
}
}
@ -92,7 +92,7 @@ class PropertiesActivity : BaseActivity<PropertiesDesign>() {
try {
withProcessing { updateStatus ->
withProfile {
patch(profile.uuid, profile.name, profile.source, profile.interval)
patch(profile.uuid, profile.name, profile.source, profile.interval, profile.ageSecretKey)
coroutineScope {
commit(profile.uuid) {

View File

@ -226,9 +226,9 @@ Java_com_github_kr328_clash_core_bridge_Bridge_nativeLoad(JNIEnv *env, jobject t
JNIEXPORT void JNICALL
Java_com_github_kr328_clash_core_bridge_Bridge_nativeFetchAndValid(JNIEnv *env, jobject thiz,
jobject callback,
jstring path,
jstring url, jboolean force) {
jobject callback,
jstring path,
jstring url, jboolean force) {
TRACE_METHOD();
jobject _completable = new_global(callback);
@ -238,6 +238,21 @@ Java_com_github_kr328_clash_core_bridge_Bridge_nativeFetchAndValid(JNIEnv *env,
fetchAndValid(_completable, _path, _url, force);
}
JNIEXPORT void JNICALL
Java_com_github_kr328_clash_core_bridge_Bridge_nativeSetAgeSecretKey(JNIEnv *env, jobject thiz,
jstring key) {
TRACE_METHOD();
if (key == NULL) {
setAgeSecretKey(NULL);
return;
}
scoped_string _key = get_string(key);
setAgeSecretKey(_key);
}
JNIEXPORT jstring JNICALL
Java_com_github_kr328_clash_core_bridge_Bridge_nativeQueryProviders(JNIEnv *env, jobject thiz) {
TRACE_METHOD();
@ -526,4 +541,4 @@ Java_com_github_kr328_clash_core_bridge_Bridge_nativeCoreVersion(JNIEnv *env, jo
char* Version = make_String(GIT_VERSION);
return new_string(Version);
}
}

View File

@ -59,4 +59,15 @@ func writeOverride(slot C.int, content C.c_string) {
//export clearOverride
func clearOverride(slot C.int) {
config.ClearOverride(config.OverrideSlot(slot))
}
}
//export setAgeSecretKey
func setAgeSecretKey(key C.c_string) {
if key == nil {
config.SetGlobalSecretKeys()
return
}
k := C.GoString(key)
config.SetGlobalSecretKeys(k)
}

View File

@ -0,0 +1,7 @@
package config
import "github.com/metacubex/mihomo/component/age"
func SetGlobalSecretKeys(secretKeys ...string) {
age.SetGlobalSecretKeys(secretKeys...)
}

View File

@ -225,4 +225,8 @@ object Clash {
})
}
}
fun setAgeSecretKey(key: String?) {
Bridge.nativeSetAgeSecretKey(key)
}
}

View File

@ -50,6 +50,8 @@ object Bridge {
external fun nativeSubscribeLogcat(callback: LogcatInterface)
external fun nativeCoreVersion(): String
external fun nativeSetAgeSecretKey(key: String?)
private external fun nativeInit(home: String, versionName: String, sdkVersion: Int)
init {

View File

@ -121,6 +121,23 @@ class PropertiesDesign(context: Context) : Design<PropertiesDesign.Request>(cont
}
}
fun inputAgeSecretKey() {
launch {
val ageSecretKey = context.requestModelTextInput(
initial = profile.ageSecretKey ?: "",
title = context.getText(R.string.age_secret_key),
hint = context.getText(R.string.age_secret_key_hint),
error = context.getText(R.string.age_secret_key_error),
validator = ValidatorAgeSecretKey
)
val newKey = ageSecretKey.ifBlank { null }
if (newKey != profile.ageSecretKey) {
profile = profile.copy(ageSecretKey = newKey)
}
}
}
fun inputInterval() {
launch {
var minutes = TimeUnit.MILLISECONDS.toMinutes(profile.interval)

View File

@ -22,4 +22,8 @@ val ValidatorHttpUrl: Validator = {
val ValidatorAutoUpdateInterval: Validator = {
it.isEmpty() || (it.toLongOrNull() ?: 0) >= 15
}
val ValidatorAgeSecretKey: Validator = {
it.isEmpty() || it.startsWith("AGE-SECRET-KEY-", ignoreCase = true)
}

View File

@ -0,0 +1,3 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android" android:height="24dp" android:tint="#FFFFFF" android:viewportHeight="24" android:viewportWidth="24" android:width="24dp">
<path android:fillColor="@android:color/white" android:pathData="M21,10h-8.35C11.83,7.67 9.61,6 7,6c-3.31,0 -6,2.69 -6,6s2.69,6 6,6c2.61,0 4.83,-1.67 5.65,-4H13l2,2l2,-2l2,2l4,-4.04L21,10zM7,15c-1.65,0 -3,-1.35 -3,-3c0,-1.65 1.35,-3 3,-3s3,1.35 3,3C10,13.65 8.65,15 7,15z"/>
</vector>

View File

@ -77,6 +77,16 @@
app:text="@{profile.source}"
app:title="@string/url" />
<com.github.kr328.clash.design.view.ActionTextField
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginVertical="@dimen/properties_element_margin_vertical"
android:onClick="@{() -> self.inputAgeSecretKey()}"
app:icon="@drawable/ic_baseline_key"
app:placeholder="@string/age_secret_key_hint"
app:text="@{profile.ageSecretKey}"
app:title="@string/age_secret_key" />
<com.github.kr328.clash.design.view.ActionTextField
android:layout_width="match_parent"
android:layout_height="wrap_content"

View File

@ -47,6 +47,9 @@
<string name="accept_http_content">http(s) のみを許可</string>
<string name="at_least_15_minutes">15分以上か空白にしてください</string>
<string name="should_not_be_blank">空白にはできません</string>
<string name="age_secret_key">Age秘密鍵</string>
<string name="age_secret_key_hint">AGE-SECRET-KEY-…(任意)</string>
<string name="age_secret_key_error">フォーマットが無効です。鍵は AGE-SECRET-KEY- で始まる必要があります</string>
<string name="detail">詳細</string>
<string name="update">更新</string>
<string name="edit">編集</string>

View File

@ -47,6 +47,9 @@
<string name="accept_http_content">http(s) 연결만 허용</string>
<string name="at_least_15_minutes">최소 15분 이상 또는 공백</string>
<string name="should_not_be_blank">이 필드는 공백이 허용되지 않습니다.</string>
<string name="age_secret_key">Age 비밀 키</string>
<string name="age_secret_key_hint">AGE-SECRET-KEY-…(선택사항)</string>
<string name="age_secret_key_error">잘못된 형식입니다. 키는 AGE-SECRET-KEY- 로 시작해야 합니다</string>
<string name="detail">자세히</string>
<string name="update">업데이트</string>
<string name="edit">편집</string>

View File

@ -57,6 +57,9 @@
<string name="accept_http_content">Принимать только http(s)</string>
<string name="at_least_15_minutes">Не менее 15 минут или пустой</string>
<string name="should_not_be_blank">Не должно быть пустым</string>
<string name="age_secret_key">Секретный ключ Age</string>
<string name="age_secret_key_hint">AGE-SECRET-KEY-… (необязательно)</string>
<string name="age_secret_key_error">Неверный формат. Ключ должен начинаться с AGE-SECRET-KEY-</string>
<string name="detail">Детали</string>
<string name="update">Обновить</string>
<string name="edit">Изменить</string>

View File

@ -25,6 +25,9 @@
<string name="access_control_packages">Các gói kiểm soát truy cập</string>
<string name="access_control_packages_summary">Định cấu hình quyền truy cập cho các ứng dụng</string>
<string name="active_unsaved_tips">Hồ sơ cần được lưu trước khi kích hoạt</string>
<string name="age_secret_key">Khóa bí mật Age</string>
<string name="age_secret_key_hint">AGE-SECRET-KEY-… (tùy chọn)</string>
<string name="age_secret_key_error">Định dạng không hợp lệ. Khóa phải bắt đầu bằng AGE-SECRET-KEY-</string>
<string name="allow_all_apps">Cho phép tất cả các ứng dụng</string>
<string name="allow_bypass">Cho phép bỏ qua</string>
<string name="allow_bypass_summary">Cho phép tất cả các ứng dụng bỏ qua kết nối VPN này</string>

View File

@ -78,6 +78,9 @@
<string name="update_time">更新時間</string>
<string name="package_name">應用包名稱</string>
<string name="install_time">安裝時間</string>
<string name="age_secret_key">Age 私鑰</string>
<string name="age_secret_key_hint">AGE-SECRET-KEY-…(可選)</string>
<string name="age_secret_key_error">格式無效。密鑰應以 AGE-SECRET-KEY- 開頭</string>
<string name="feedback">反饋</string>
<string name="github_issues">Github Issues</string>
<string name="tips_properties"><![CDATA[僅接受 <strong>Clash 配置文件</strong>(包含<strong>代理</strong>/<strong>規則</strong>)]]></string>

View File

@ -78,6 +78,9 @@
<string name="update_time">更新時間</string>
<string name="package_name">套件名稱</string>
<string name="install_time">安裝時間</string>
<string name="age_secret_key">Age 私鑰</string>
<string name="age_secret_key_hint">AGE-SECRET-KEY-…(選填)</string>
<string name="age_secret_key_error">格式無效。金鑰應以 AGE-SECRET-KEY- 開頭</string>
<string name="feedback">回饋</string>
<string name="github_issues">Github Issues</string>
<string name="tips_properties"><![CDATA[僅接受 <strong>Clash 設定檔</strong> (包含<strong>Proxy</strong> /<strong>規則</strong>)]]></string>

View File

@ -78,6 +78,9 @@
<string name="vpn_service_options">VpnService 选项</string>
<string name="options_unavailable">选项在 Clash 运行时不可用</string>
<string name="search">查找</string>
<string name="age_secret_key">Age 私钥</string>
<string name="age_secret_key_hint">AGE-SECRET-KEY-…(可选)</string>
<string name="age_secret_key_error">格式无效。密钥应以 AGE-SECRET-KEY- 开头</string>
<string name="system_apps">系统应用</string>
<string name="update_time">更新时间</string>
<string name="package_name">应用包名称</string>

View File

@ -62,6 +62,9 @@
<string name="accept_http_content">Accept only http(s)</string>
<string name="at_least_15_minutes">At least 15 minutes or empty</string>
<string name="age_secret_key">Age Secret Key</string>
<string name="age_secret_key_hint">AGE-SECRET-KEY-… (optional)</string>
<string name="age_secret_key_error">Invalid format. Key should start with AGE-SECRET-KEY-</string>
<string name="should_not_be_blank">Should not be blank</string>
<string name="detail">Detail</string>
<string name="update">Update</string>

View File

@ -37,7 +37,7 @@ class ProfileManager(private val context: Context) : IProfileManager,
}
}
override suspend fun create(type: Profile.Type, name: String, source: String): UUID {
override suspend fun create(type: Profile.Type, name: String, source: String, ageSecretKey: String?): UUID {
val uuid = generateProfileUUID()
val pending = Pending(
uuid = uuid,
@ -49,6 +49,7 @@ class ProfileManager(private val context: Context) : IProfileManager,
total = 0,
download = 0,
expire = 0,
ageSecretKey = ageSecretKey,
)
PendingDao().insert(pending)
@ -81,6 +82,7 @@ class ProfileManager(private val context: Context) : IProfileManager,
total = imported.total,
download = imported.download,
expire = imported.expire,
ageSecretKey = imported.ageSecretKey
)
cloneImportedFiles(uuid, newUUID)
@ -90,7 +92,7 @@ class ProfileManager(private val context: Context) : IProfileManager,
return newUUID
}
override suspend fun patch(uuid: UUID, name: String, source: String, interval: Long) {
override suspend fun patch(uuid: UUID, name: String, source: String, interval: Long, ageSecretKey: String?) {
val pending = PendingDao().queryByUUID(uuid)
if (pending == null) {
@ -110,6 +112,7 @@ class ProfileManager(private val context: Context) : IProfileManager,
total = 0,
download = 0,
expire = 0,
ageSecretKey = ageSecretKey,
)
)
} else {
@ -121,6 +124,7 @@ class ProfileManager(private val context: Context) : IProfileManager,
total = 0,
download = 0,
expire = 0,
ageSecretKey = ageSecretKey,
)
PendingDao().update(newPending)
@ -188,7 +192,8 @@ class ProfileManager(private val context: Context) : IProfileManager,
download,
total,
expire,
old?.createdAt ?: System.currentTimeMillis()
old?.createdAt ?: System.currentTimeMillis(),
ageSecretKey = old.ageSecretKey
)
if (old != null) {
@ -266,19 +271,20 @@ class ProfileManager(private val context: Context) : IProfileManager,
val expire = pending?.expire ?: imported?.expire ?: return null
return Profile(
uuid,
name,
type,
source,
active != null && imported?.uuid == active,
interval,
upload,
download,
total,
expire,
resolveUpdatedAt(uuid),
imported != null,
pending != null
uuid = uuid,
name = name,
type = type,
source = source,
active = active != null && imported?.uuid == active,
interval = interval,
upload = upload,
download = download,
total = total,
expire = expire,
updatedAt = resolveUpdatedAt(uuid),
imported = imported != null,
pending = pending != null,
ageSecretKey = if (pending != null) pending.ageSecretKey else imported?.ageSecretKey,
)
}
@ -309,4 +315,4 @@ class ProfileManager(private val context: Context) : IProfileManager,
ProfileReceiver.scheduleNext(context, imported)
}
}
}
}

View File

@ -22,7 +22,6 @@ import kotlinx.coroutines.withContext
import okhttp3.OkHttpClient
import okhttp3.Request
import java.math.BigDecimal
import java.net.URL
import java.util.*
import java.util.concurrent.TimeUnit
@ -34,8 +33,8 @@ object ProfileProcessor {
withContext(NonCancellable) {
processLock.withLock {
val snapshot = profileLock.withLock {
val pending = PendingDao().queryByUUID(uuid)
?: throw IllegalArgumentException("profile $uuid not found")
val pending =
PendingDao().queryByUUID(uuid) ?: throw IllegalArgumentException("profile $uuid not found")
pending.enforceFieldValid()
@ -48,6 +47,8 @@ object ProfileProcessor {
pending
}
Clash.setAgeSecretKey(snapshot.ageSecretKey?.takeIf { it.isNotBlank() })
val force = snapshot.type != Profile.Type.File
var cb = callback
@ -63,10 +64,8 @@ object ProfileProcessor {
profileLock.withLock {
if (PendingDao().queryByUUID(snapshot.uuid) == snapshot) {
context.importedDir.resolve(snapshot.uuid.toString())
.deleteRecursively()
context.processingDir
.copyRecursively(context.importedDir.resolve(snapshot.uuid.toString()))
context.importedDir.resolve(snapshot.uuid.toString()).deleteRecursively()
context.processingDir.copyRecursively(context.importedDir.resolve(snapshot.uuid.toString()))
val old = ImportedDao().queryByUUID(snapshot.uuid)
var upload: Long = 0
@ -77,11 +76,10 @@ object ProfileProcessor {
if (snapshot?.type == Profile.Type.Url) {
if (snapshot.source.startsWith("https://", true)) {
val client = OkHttpClient()
val versionName = context.packageManager.getPackageInfo(context.packageName, 0).versionName
val request = Request.Builder()
.url(snapshot.source)
.header("User-Agent", "ClashMetaForAndroid/$versionName")
.build()
val versionName =
context.packageManager.getPackageInfo(context.packageName, 0).versionName
val request = Request.Builder().url(snapshot.source)
.header("User-Agent", "ClashMetaForAndroid/$versionName").build()
client.newCall(request).execute().use { response ->
val userinfo = response.headers["subscription-userinfo"]
@ -99,7 +97,7 @@ object ProfileProcessor {
info[0].contains("total") && info[1].isNotEmpty() -> total =
BigDecimal(info[1].split('.').first()).longValueExact()
info[0].contains("expire") && info[1].isNotEmpty() -> expire =
info[0].contains("expire") && info[1].isNotEmpty() -> expire =
(info[1].toDouble() * 1000).toLong()
}
}
@ -110,8 +108,8 @@ object ProfileProcessor {
val intervalHours = updateIntervalHeader.toLongOrNull()
if (intervalHours != null) {
updateInterval = if (intervalHours > 0) {
java.util.concurrent.TimeUnit.HOURS.toMillis(intervalHours)
.coerceAtLeast(java.util.concurrent.TimeUnit.MINUTES.toMillis(15))
TimeUnit.HOURS.toMillis(intervalHours)
.coerceAtLeast(TimeUnit.MINUTES.toMillis(15))
} else {
0L
}
@ -129,7 +127,8 @@ object ProfileProcessor {
download,
total,
expire,
old?.createdAt ?: System.currentTimeMillis()
old?.createdAt ?: System.currentTimeMillis(),
ageSecretKey = snapshot.ageSecretKey
)
if (old != null) {
ImportedDao().update(new)
@ -139,8 +138,7 @@ object ProfileProcessor {
PendingDao().remove(snapshot.uuid)
context.pendingDir.resolve(snapshot.uuid.toString())
.deleteRecursively()
context.pendingDir.resolve(snapshot.uuid.toString()).deleteRecursively()
context.sendProfileChanged(snapshot.uuid)
} else if (snapshot?.type == Profile.Type.File) {
@ -154,7 +152,8 @@ object ProfileProcessor {
download,
total,
expire,
old?.createdAt ?: System.currentTimeMillis()
old?.createdAt ?: System.currentTimeMillis(),
ageSecretKey = snapshot.ageSecretKey
)
if (old != null) {
ImportedDao().update(new)
@ -164,8 +163,7 @@ object ProfileProcessor {
PendingDao().remove(snapshot.uuid)
context.pendingDir.resolve(snapshot.uuid.toString())
.deleteRecursively()
context.pendingDir.resolve(snapshot.uuid.toString()).deleteRecursively()
context.sendProfileChanged(snapshot.uuid)
}
@ -179,8 +177,8 @@ object ProfileProcessor {
withContext(NonCancellable) {
processLock.withLock {
val snapshot = profileLock.withLock {
val imported = ImportedDao().queryByUUID(uuid)
?: throw IllegalArgumentException("profile $uuid not found")
val imported =
ImportedDao().queryByUUID(uuid) ?: throw IllegalArgumentException("profile $uuid not found")
context.processingDir.deleteRecursively()
context.processingDir.mkdirs()
@ -191,6 +189,8 @@ object ProfileProcessor {
imported
}
Clash.setAgeSecretKey(snapshot.ageSecretKey?.takeIf { it.isNotBlank() })
var cb = callback
Clash.fetchAndValid(context.processingDir, snapshot.source, true) {
@ -206,8 +206,7 @@ object ProfileProcessor {
profileLock.withLock {
if (ImportedDao().exists(snapshot.uuid)) {
context.importedDir.resolve(snapshot.uuid.toString()).deleteRecursively()
context.processingDir
.copyRecursively(context.importedDir.resolve(snapshot.uuid.toString()))
context.processingDir.copyRecursively(context.importedDir.resolve(snapshot.uuid.toString()))
context.sendProfileChanged(snapshot.uuid)
}
@ -261,17 +260,16 @@ object ProfileProcessor {
val scheme = Uri.parse(source)?.scheme?.lowercase(Locale.getDefault())
when {
name.isBlank() ->
throw IllegalArgumentException("Empty name")
name.isBlank() -> throw IllegalArgumentException("Empty name")
source.isEmpty() && type != Profile.Type.File ->
throw IllegalArgumentException("Invalid url")
source.isEmpty() && type != Profile.Type.File -> throw IllegalArgumentException("Invalid url")
source.isNotEmpty() && scheme != "https" && scheme != "http" && scheme != "content" ->
throw IllegalArgumentException("Unsupported url $source")
source.isNotEmpty() && scheme != "https" && scheme != "http" && scheme != "content" -> throw IllegalArgumentException(
"Unsupported url $source"
)
interval != 0L && TimeUnit.MILLISECONDS.toMinutes(interval) < 15 ->
throw IllegalArgumentException("Invalid interval")
interval != 0L && TimeUnit.MILLISECONDS.toMinutes(interval) < 15 -> throw IllegalArgumentException("Invalid interval")
}
}
}
}

View File

@ -55,6 +55,8 @@ class ConfigurationModule(service: Service) : Module<ConfigurationModule.LoadExc
val active = ImportedDao().queryByUUID(current)
?: throw NullPointerException("No profile selected")
Clash.setAgeSecretKey(active.ageSecretKey?.takeIf { it.isNotBlank() })
Clash.load(service.importedDir.resolve(active.uuid.toString())).await()
val remove = SelectionDao().querySelections(active.uuid)
@ -73,4 +75,4 @@ class ConfigurationModule(service: Service) : Module<ConfigurationModule.LoadExc
}
}
}
}
}

View File

@ -12,7 +12,7 @@ import java.lang.ref.SoftReference
import androidx.room.Database as DB
@DB(
version = 1,
version = 2,
entities = [Imported::class, Pending::class, Selection::class],
exportSchema = false,
)
@ -45,4 +45,4 @@ abstract class Database : RoomDatabase() {
}
}
}
}
}

View File

@ -19,4 +19,5 @@ data class Imported(
@ColumnInfo(name = "total") val total: Long,
@ColumnInfo(name = "expire") val expire: Long,
@ColumnInfo(name = "createdAt") val createdAt: Long,
@ColumnInfo(name = "ageSecretKey") val ageSecretKey: String? = null,
)

View File

@ -19,4 +19,5 @@ data class Pending(
@ColumnInfo(name = "total") val total: Long,
@ColumnInfo(name = "expire") val expire: Long,
@ColumnInfo(name = "createdAt") val createdAt: Long = System.currentTimeMillis(),
@ColumnInfo(name = "ageSecretKey") val ageSecretKey: String? = null,
)

View File

@ -1,7 +1,17 @@
package com.github.kr328.clash.service.data.migrations
import androidx.room.migration.Migration
import androidx.sqlite.db.SupportSQLiteDatabase
val MIGRATIONS: Array<Migration> = arrayOf()
private val MIGRATION_1_2 = object : Migration(1, 2) {
override fun migrate(database: SupportSQLiteDatabase) {
database.execSQL("ALTER TABLE imported ADD COLUMN ageSecretKey TEXT")
database.execSQL("ALTER TABLE pending ADD COLUMN ageSecretKey TEXT")
}
}
val LEGACY_MIGRATION = ::migrationFromLegacy
val MIGRATIONS: Array<Migration> = arrayOf(
MIGRATION_1_2,
)
val LEGACY_MIGRATION = ::migrationFromLegacy

View File

@ -134,7 +134,8 @@ class Picker(private val context: Context) {
imported.type,
imported.source,
imported.interval,
0,0,0,0
0,0,0,0,
ageSecretKey = imported.ageSecretKey
)
)

View File

@ -22,11 +22,10 @@ data class Profile(
var download: Long,
val total: Long,
val expire: Long,
val updatedAt: Long,
val imported: Boolean,
val pending: Boolean,
val ageSecretKey: String? = null,
) : Parcelable {
enum class Type {
File, Url, External
@ -49,4 +48,4 @@ data class Profile(
return arrayOfNulls(size)
}
}
}
}

View File

@ -6,15 +6,15 @@ import java.util.*
@BinderInterface
interface IProfileManager {
suspend fun create(type: Profile.Type, name: String, source: String = ""): UUID
suspend fun create(type: Profile.Type, name: String, source: String = "", ageSecretKey: String? = null): UUID
suspend fun clone(uuid: UUID): UUID
suspend fun commit(uuid: UUID, callback: IFetchObserver? = null)
suspend fun release(uuid: UUID)
suspend fun delete(uuid: UUID)
suspend fun patch(uuid: UUID, name: String, source: String, interval: Long)
suspend fun patch(uuid: UUID, name: String, source: String, interval: Long, ageSecretKey: String?)
suspend fun update(uuid: UUID)
suspend fun queryByUUID(uuid: UUID): Profile?
suspend fun queryAll(): List<Profile>
suspend fun queryActive(): Profile?
suspend fun setActive(profile: Profile)
}
}