snapshot of https://github.com/toeverything/AFFiNE.git (canary, 2026-09-16; blobs > 131072B omitted)
Some checks failed
Build & Test / Analyze (javascript, affine) (push) Failing after 4s
Build & Test / Analyze (typescript, affine) (push) Failing after 3s
Build & Test / Rust test filter (push) Failing after 4s
Build & Test / Check yarn binary (push) Failing after 3s
Build & Test / E2E BlockSuite Test (3) (push) Failing after 3s
Build & Test / Analyze (javascript, blocksuite) (push) Failing after 3s
Build & Test / Analyze (typescript, blocksuite) (push) Failing after 3s
Build & Test / Mobile native build filter (push) Failing after 3s
Build & Test / E2E BlockSuite Test (1) (push) Failing after 4s
Build & Test / E2E BlockSuite Test (2) (push) Failing after 4s
Build & Test / E2E Mobile Test (1) (push) Failing after 3s
Build & Test / Unit Test (1) (push) Has been skipped
Build & Test / Unit Test (2) (push) Has been skipped
Build & Test / Unit Test (3) (push) Has been skipped
Build & Test / Unit Test (4) (push) Has been skipped
Build & Test / Unit Test (5) (push) Has been skipped
Build & Test / Native Unit Test (push) Has been skipped
Build & Test / E2E BlockSuite Test (4) (push) Failing after 3s
Build & Test / E2E BlockSuite Test (5) (push) Failing after 3s
Build & Test / E2E BlockSuite Cross Browser Test (push) Failing after 3s
Build & Test / E2E Mobile Test (2) (push) Failing after 3s
Build & Test / Build AFFiNE native (x86_64-unknown-linux-gnu) (push) Failing after 3s
Build & Test / Build Server native (push) Failing after 3s
Build & Test / Check Git Status (push) Has been skipped
Build & Test / Server Test (0, 4) (push) Has been skipped
Build & Test / Server Test (1, 4) (push) Has been skipped
Build & Test / Server Test (2, 4) (push) Has been skipped
Build & Test / Server Test (3, 4) (push) Has been skipped
Build & Test / Server Test with Elasticsearch (push) Has been skipped
Build & Test / Build @affine/electron renderer (push) Failing after 3s
Build & Test / Copilot test filter (push) Failing after 4s
Build & Test / Lint Rust (push) Has been skipped
Build & Test / Server Test with Embedded Search (push) Has been skipped
Build & Test / Server Test with Manticore Search (push) Has been skipped
Build & Test / Server E2E Test (push) Has been skipped
Build & Test / Cloud Desktop E2E Test (push) Has been skipped
Build & Test / Cloud E2E Test 1/2 (push) Has been skipped
Build & Test / Cloud E2E Test 2/2 (push) Has been skipped
Build & Test / Build Android app (push) Has been skipped
Build & Test / Test Android app (push) Has been skipped
Build & Test / Run native tests (push) Has been skipped
Build & Test / Server Copilot Api Test (push) Has been skipped
Build & Test / Frontend Copilot E2E Test (1, 5) (push) Has been skipped
Build & Test / Frontend Copilot E2E Test (2, 5) (push) Has been skipped
Build & Test / Frontend Copilot E2E Test (3, 5) (push) Has been skipped
Build & Test / Frontend Copilot E2E Test (4, 5) (push) Has been skipped
Build & Test / Frontend Copilot E2E Test (5, 5) (push) Has been skipped
Release / Canary Gate (push) Has been skipped
Build & Test / Build AFFiNE native (aarch64-apple-darwin) (push) Has been cancelled
Build & Test / Lint (push) Has been cancelled
Build & Test / Typecheck (push) Has been cancelled
Build & Test / Build AFFiNE native (x86_64-apple-darwin) (push) Has been cancelled
Build & Test / Desktop Test (ubuntu-latest, linux, x64, x86_64-unknown-linux-gnu, true) (push) Has been cancelled
Build & Test / Desktop Test (windows-latest, windows, x64, x86_64-pc-windows-msvc, true) (push) Has been cancelled
Build & Test / E2E Test (1) (push) Has been cancelled
Build & Test / E2E Test (2) (push) Has been cancelled
Build & Test / E2E Test (3) (push) Has been cancelled
Build & Test / E2E Test (4) (push) Has been cancelled
Build & Test / E2E Test (5) (push) Has been cancelled
Build & Test / Build AFFiNE native (aarch64-pc-windows-msvc) (push) Has been cancelled
Build & Test / Build AFFiNE native (x86_64-pc-windows-msvc) (push) Has been cancelled
Build & Test / Desktop Test (macos-latest, macos, arm64, aarch64-apple-darwin, true) (push) Has been cancelled
Build & Test / Desktop Test (macos-latest, macos, x64, x86_64-apple-darwin, false) (push) Has been cancelled
Build & Test / 3, 2, 1 Launch (push) Has been cancelled
Build & Test / Build iOS app (push) Has been cancelled
Copilot Test Automatically / Setup Test (push) Failing after 1s
Release / Prepare (push) Failing after 2s
Release / Release Cloud (push) Has been skipped
Release / Release Desktop (push) Has been skipped
Release / Release Docker Image (push) Has been skipped
Release / Release Mobile (push) Has been skipped
@@ -0,0 +1,77 @@
|
||||
package app.affine.pro
|
||||
|
||||
import androidx.activity.BackEventCompat
|
||||
import androidx.activity.OnBackPressedCallback
|
||||
import androidx.test.core.app.ActivityScenario
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
|
||||
@RunWith(AndroidJUnit4::class)
|
||||
class MobileBackDispatcherTest {
|
||||
@Test
|
||||
fun disabledAppCallbackPassesTheSameBackToTheNextCallback() {
|
||||
ActivityScenario.launch(MainActivity::class.java).use { scenario ->
|
||||
scenario.onActivity { activity ->
|
||||
var fallbackCalled = false
|
||||
activity.onBackPressedDispatcher.addCallback(
|
||||
object : OnBackPressedCallback(true) {
|
||||
override fun handleOnBackPressed() {
|
||||
fallbackCalled = true
|
||||
}
|
||||
},
|
||||
)
|
||||
val appCallback = object : OnBackPressedCallback(true) {
|
||||
override fun handleOnBackPressed() = Unit
|
||||
}
|
||||
activity.onBackPressedDispatcher.addCallback(appCallback)
|
||||
|
||||
appCallback.isEnabled = false
|
||||
activity.onBackPressedDispatcher.onBackPressed()
|
||||
|
||||
assertTrue(fallbackCalled)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun predictiveLifecycleTargetsOneEnabledCallback() {
|
||||
ActivityScenario.launch(MainActivity::class.java).use { scenario ->
|
||||
scenario.onActivity { activity ->
|
||||
val phases = mutableListOf<String>()
|
||||
activity.onBackPressedDispatcher.addCallback(
|
||||
object : OnBackPressedCallback(true) {
|
||||
override fun handleOnBackStarted(backEvent: BackEventCompat) {
|
||||
phases.add("begin")
|
||||
}
|
||||
|
||||
override fun handleOnBackProgressed(backEvent: BackEventCompat) {
|
||||
phases.add("progress")
|
||||
}
|
||||
|
||||
override fun handleOnBackCancelled() {
|
||||
phases.add("cancel")
|
||||
}
|
||||
|
||||
override fun handleOnBackPressed() {
|
||||
phases.add("commit")
|
||||
}
|
||||
},
|
||||
)
|
||||
val event = BackEventCompat(0f, 0f, 0.5f, BackEventCompat.EDGE_LEFT)
|
||||
activity.onBackPressedDispatcher.dispatchOnBackStarted(event)
|
||||
activity.onBackPressedDispatcher.dispatchOnBackProgressed(event)
|
||||
activity.onBackPressedDispatcher.dispatchOnBackCancelled()
|
||||
activity.onBackPressedDispatcher.dispatchOnBackStarted(event)
|
||||
activity.onBackPressedDispatcher.onBackPressed()
|
||||
|
||||
assertEquals(
|
||||
listOf("begin", "progress", "cancel", "begin", "commit"),
|
||||
phases,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,237 @@
|
||||
package app.affine.pro.plugin
|
||||
|
||||
import android.content.Context
|
||||
import androidx.datastore.preferences.preferencesDataStore
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||
import androidx.test.platform.app.InstrumentationRegistry
|
||||
import app.affine.pro.utils.authDataStoreCorruptionHandler
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.async
|
||||
import kotlinx.coroutines.awaitAll
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import kotlinx.coroutines.withContext
|
||||
import kotlinx.coroutines.withTimeout
|
||||
import okhttp3.mockwebserver.MockResponse
|
||||
import okhttp3.mockwebserver.MockWebServer
|
||||
import org.json.JSONObject
|
||||
import org.junit.After
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertNotNull
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Assert.assertThrows
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Before
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import java.text.SimpleDateFormat
|
||||
import java.util.Date
|
||||
import java.util.Locale
|
||||
import java.util.TimeZone
|
||||
import java.util.UUID
|
||||
import java.util.concurrent.TimeUnit
|
||||
|
||||
private val Context.corruptionTestDataStore by preferencesDataStore(
|
||||
name = "auth-session-corruption-test",
|
||||
corruptionHandler = authDataStoreCorruptionHandler,
|
||||
)
|
||||
|
||||
@RunWith(AndroidJUnit4::class)
|
||||
class AuthSessionBrokerTest {
|
||||
private lateinit var server: MockWebServer
|
||||
private lateinit var endpoint: String
|
||||
private lateinit var broker: AuthSessionBroker
|
||||
|
||||
@Before
|
||||
fun setUp() = runBlocking {
|
||||
server = MockWebServer()
|
||||
server.start()
|
||||
endpoint = server.url("/").toString().removeSuffix("/")
|
||||
broker = AuthSessionBroker()
|
||||
broker.clear(endpoint)
|
||||
}
|
||||
|
||||
@After
|
||||
fun tearDown() = runBlocking {
|
||||
broker.clear(endpoint)
|
||||
server.shutdown()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun persistsEncryptedPairAcrossBrokerRecreation() = runBlocking {
|
||||
broker.store(endpoint, tokenResponse("access-one", refreshToken('a'), 900))
|
||||
|
||||
assertEquals("access-one", AuthSessionBroker().validAccessToken(endpoint))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun concurrentCallersShareOneRefresh() = runBlocking {
|
||||
broker.store(endpoint, tokenResponse("expired", refreshToken('a'), 1))
|
||||
Thread.sleep(1_100)
|
||||
server.enqueue(MockResponse().setBody(tokenResponse("fresh", refreshToken('b'), 900)))
|
||||
|
||||
val tokens = List(20) { async { broker.validAccessToken(endpoint) } }.awaitAll()
|
||||
|
||||
assertTrue(tokens.all { it == "fresh" })
|
||||
assertEquals(1, server.requestCount)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun retriesPendingRotationBeforeStartingAnotherRefresh() = runBlocking {
|
||||
val storage = FakeStorage()
|
||||
val testBroker = AuthSessionBroker(storage)
|
||||
testBroker.store(endpoint, tokenResponse("expired", refreshToken('a'), 1))
|
||||
Thread.sleep(1_100)
|
||||
storage.failWrites = 1
|
||||
server.enqueue(MockResponse().setBody(tokenResponse("fresh", refreshToken('b'), 900)))
|
||||
|
||||
assertThrows(Exception::class.java) {
|
||||
runBlocking { testBroker.refreshAccessToken(endpoint) }
|
||||
}
|
||||
assertEquals("fresh", testBroker.refreshAccessToken(endpoint))
|
||||
assertEquals(1, server.requestCount)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun corruptedCredentialBecomesLocalCredentialLoss() = runBlocking {
|
||||
val storage = FakeStorage().apply { value = "not-json" }
|
||||
|
||||
assertNull(AuthSessionBroker(storage).validAccessToken(endpoint))
|
||||
assertNull(storage.value)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun corruptedDataStoreFileIsReplacedWithEmptyPreferences() = runBlocking {
|
||||
val context = InstrumentationRegistry.getInstrumentation().targetContext
|
||||
val file = context.filesDir.resolve("datastore/auth-session-corruption-test.preferences_pb")
|
||||
file.parentFile?.mkdirs()
|
||||
file.writeBytes(byteArrayOf(0x7f, 0x01, 0x02, 0x03))
|
||||
|
||||
val preferences = withTimeout(5_000) {
|
||||
context.corruptionTestDataStore.data.first()
|
||||
}
|
||||
assertTrue(preferences.asMap().isEmpty())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun transientRefreshFailurePreservesStoredCredential() = runBlocking {
|
||||
val storage = FakeStorage()
|
||||
val testBroker = AuthSessionBroker(storage)
|
||||
testBroker.store(endpoint, tokenResponse("expired", refreshToken('a'), 1))
|
||||
Thread.sleep(1_100)
|
||||
repeat(3) { server.enqueue(MockResponse().setResponseCode(503)) }
|
||||
|
||||
assertThrows(Exception::class.java) {
|
||||
runBlocking { testBroker.refreshAccessToken(endpoint) }
|
||||
}
|
||||
assertTrue(storage.value?.contains("expired") == true)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun clearWinsAgainstInflightRefresh() = runBlocking {
|
||||
broker.store(endpoint, tokenResponse("expired", refreshToken('a'), 1))
|
||||
Thread.sleep(1_100)
|
||||
server.enqueue(
|
||||
MockResponse()
|
||||
.setBody(tokenResponse("stale", refreshToken('b'), 900))
|
||||
.setBodyDelay(500, TimeUnit.MILLISECONDS),
|
||||
)
|
||||
|
||||
val refresh = async { broker.refreshAccessToken(endpoint) }
|
||||
assertNotNull(withContext(Dispatchers.IO) { server.takeRequest(5, TimeUnit.SECONDS) })
|
||||
broker.clear(endpoint)
|
||||
|
||||
assertThrows(Exception::class.java) { runBlocking { refresh.await() } }
|
||||
assertNull(broker.validAccessToken(endpoint))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun newLoginWinsAgainstInflightRefresh() = runBlocking {
|
||||
broker.store(endpoint, tokenResponse("expired", refreshToken('a'), 1))
|
||||
Thread.sleep(1_100)
|
||||
server.enqueue(
|
||||
MockResponse()
|
||||
.setBody(tokenResponse("stale", refreshToken('b'), 900))
|
||||
.setBodyDelay(500, TimeUnit.MILLISECONDS),
|
||||
)
|
||||
|
||||
val refresh = async { broker.refreshAccessToken(endpoint) }
|
||||
assertNotNull(withContext(Dispatchers.IO) { server.takeRequest(5, TimeUnit.SECONDS) })
|
||||
broker.store(endpoint, tokenResponse("new-login", refreshToken('c'), 900))
|
||||
|
||||
assertThrows(Exception::class.java) { runBlocking { refresh.await() } }
|
||||
assertEquals("new-login", broker.validAccessToken(endpoint))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun signOutWinsAgainstInflightRefresh() = runBlocking {
|
||||
broker.store(endpoint, tokenResponse("expired", refreshToken('a'), 1))
|
||||
Thread.sleep(1_100)
|
||||
server.enqueue(
|
||||
MockResponse()
|
||||
.setBody(tokenResponse("stale", refreshToken('b'), 900))
|
||||
.setBodyDelay(500, TimeUnit.MILLISECONDS),
|
||||
)
|
||||
server.enqueue(MockResponse().setBody("{}"))
|
||||
|
||||
val refresh = async { broker.refreshAccessToken(endpoint) }
|
||||
assertNotNull(withContext(Dispatchers.IO) { server.takeRequest(5, TimeUnit.SECONDS) })
|
||||
broker.signOut(endpoint)
|
||||
|
||||
assertThrows(Exception::class.java) { runBlocking { refresh.await() } }
|
||||
assertNull(broker.validAccessToken(endpoint))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun invalidatedEndpointKeyIsRecreatedWithoutPlaintextFallback() {
|
||||
val cipher = TokenCipher()
|
||||
val value = cipher.encrypt(endpoint, "secret")
|
||||
assertEquals("secret", cipher.decrypt(endpoint, value))
|
||||
|
||||
cipher.reset(endpoint)
|
||||
assertNull(cipher.decrypt(endpoint, value))
|
||||
assertEquals("replacement", cipher.decrypt(endpoint, cipher.encrypt(endpoint, "replacement")))
|
||||
}
|
||||
|
||||
private fun tokenResponse(access: String, refresh: String, expiresIn: Int) =
|
||||
JSONObject()
|
||||
.put("tokenType", "Bearer")
|
||||
.put("accessToken", access)
|
||||
.put("expiresIn", expiresIn)
|
||||
.put("refreshToken", refresh)
|
||||
.put("refreshExpiresAt", isoAfter(86_400))
|
||||
.put(
|
||||
"session",
|
||||
JSONObject()
|
||||
.put("id", UUID.randomUUID().toString())
|
||||
.put("absoluteExpiresAt", isoAfter(172_800)),
|
||||
)
|
||||
.toString()
|
||||
|
||||
private fun refreshToken(seed: Char) =
|
||||
"aff_rt_v1.${seed.toString().repeat(24)}.${seed.toString().repeat(43)}"
|
||||
|
||||
private fun isoAfter(seconds: Long) =
|
||||
SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss.SSS'Z'", Locale.US).apply {
|
||||
timeZone = TimeZone.getTimeZone("UTC")
|
||||
}.format(Date(System.currentTimeMillis() + seconds * 1000))
|
||||
|
||||
private class FakeStorage : AuthCredentialStorage {
|
||||
var value: String? = null
|
||||
var failWrites = 0
|
||||
|
||||
override suspend fun read(endpoint: String) = value
|
||||
|
||||
override suspend fun write(endpoint: String, value: String) {
|
||||
if (failWrites > 0) {
|
||||
failWrites--
|
||||
throw IllegalStateException("storage unavailable")
|
||||
}
|
||||
this.value = value
|
||||
}
|
||||
|
||||
override suspend fun delete(endpoint: String) {
|
||||
value = null
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
package com.getcapacitor.myapp;
|
||||
|
||||
import static org.junit.Assert.*;
|
||||
|
||||
import android.content.Context;
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4;
|
||||
import androidx.test.platform.app.InstrumentationRegistry;
|
||||
import org.junit.Test;
|
||||
import org.junit.runner.RunWith;
|
||||
|
||||
/**
|
||||
* Instrumented test, which will execute on an Android device.
|
||||
*
|
||||
* @see <a href="http://d.android.com/tools/testing">Testing documentation</a>
|
||||
*/
|
||||
@RunWith(AndroidJUnit4.class)
|
||||
public class ExampleInstrumentedTest {
|
||||
|
||||
@Test
|
||||
public void useAppContext() throws Exception {
|
||||
// Context of the app under test.
|
||||
Context appContext = InstrumentationRegistry.getInstrumentation().getTargetContext();
|
||||
|
||||
assertEquals("app.affine.pro", appContext.getPackageName());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
|
||||
<queries>
|
||||
<intent>
|
||||
<action android:name="android.support.customtabs.action.CustomTabsService" />
|
||||
</intent>
|
||||
</queries>
|
||||
|
||||
<application
|
||||
android:name=".AFFiNEApp"
|
||||
android:allowBackup="true"
|
||||
android:icon="@mipmap/ic_launcher"
|
||||
android:label="@string/app_name"
|
||||
android:roundIcon="@mipmap/ic_launcher_round"
|
||||
android:hardwareAccelerated="true"
|
||||
android:supportsRtl="true"
|
||||
android:usesCleartextTraffic="${usesCleartextTraffic}"
|
||||
android:theme="@style/AppTheme">
|
||||
|
||||
<activity
|
||||
android:name=".MainActivity"
|
||||
android:configChanges="orientation|keyboardHidden|keyboard|screenSize|locale|smallestScreenSize|screenLayout|uiMode|navigation|density"
|
||||
android:exported="true"
|
||||
android:hardwareAccelerated="true"
|
||||
android:label="@string/title_activity_main"
|
||||
android:launchMode="singleTask"
|
||||
android:theme="@style/AppTheme.NoActionBarLaunch">
|
||||
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.MAIN" />
|
||||
<category android:name="android.intent.category.LAUNCHER" />
|
||||
</intent-filter>
|
||||
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.VIEW" />
|
||||
|
||||
<category android:name="android.intent.category.DEFAULT" />
|
||||
<category android:name="android.intent.category.BROWSABLE" />
|
||||
|
||||
<data
|
||||
android:host="authentication"
|
||||
android:pathPattern=".*"
|
||||
android:scheme="affine" />
|
||||
</intent-filter>
|
||||
</activity>
|
||||
|
||||
<activity
|
||||
android:name=".ai.AIActivity"
|
||||
android:theme="@style/AppTheme.NoActionBar"
|
||||
android:windowSoftInputMode="adjustResize" />
|
||||
|
||||
<provider
|
||||
android:name="androidx.core.content.FileProvider"
|
||||
android:authorities="${applicationId}.fileprovider"
|
||||
android:exported="false"
|
||||
android:grantUriPermissions="true">
|
||||
<meta-data
|
||||
android:name="android.support.FILE_PROVIDER_PATHS"
|
||||
android:resource="@xml/file_paths" />
|
||||
</provider>
|
||||
</application>
|
||||
|
||||
<!-- Permissions -->
|
||||
|
||||
<uses-permission android:name="android.permission.INTERNET" />
|
||||
</manifest>
|
||||
|
After Width: | Height: | Size: 28 KiB |
@@ -0,0 +1,79 @@
|
||||
package app.affine.pro
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import android.app.Application
|
||||
import android.content.Context
|
||||
import android.os.SystemClock
|
||||
import androidx.webkit.WebViewCompat
|
||||
import androidx.webkit.WebViewOutcomeReceiver
|
||||
import androidx.webkit.WebViewStartUpConfig
|
||||
import androidx.webkit.WebViewStartUpResult
|
||||
import androidx.webkit.WebViewStartupException
|
||||
import app.affine.pro.utils.logger.AffineDebugTree
|
||||
import app.affine.pro.utils.logger.CrashlyticsTree
|
||||
import app.affine.pro.utils.logger.FileTree
|
||||
import com.google.firebase.crashlytics.ktx.crashlytics
|
||||
import com.google.firebase.crashlytics.setCustomKeys
|
||||
import com.google.firebase.ktx.Firebase
|
||||
import dagger.hilt.android.HiltAndroidApp
|
||||
import timber.log.Timber
|
||||
import java.util.concurrent.Executors
|
||||
|
||||
@HiltAndroidApp
|
||||
class AFFiNEApp : Application() {
|
||||
|
||||
override fun onCreate() {
|
||||
super.onCreate()
|
||||
startWebView()
|
||||
_context = applicationContext
|
||||
// init logger
|
||||
if (BuildConfig.DEBUG) {
|
||||
Timber.plant(AffineDebugTree())
|
||||
} else {
|
||||
Timber.plant(CrashlyticsTree(), FileTree(applicationContext))
|
||||
}
|
||||
Timber.i("Application started.")
|
||||
// init capacitor config
|
||||
CapacitorConfig.init(baseContext)
|
||||
// init crashlytics
|
||||
Firebase.crashlytics.setCustomKeys {
|
||||
key("affine_version", CapacitorConfig.getAffineVersion())
|
||||
}
|
||||
}
|
||||
|
||||
private fun startWebView() {
|
||||
val startedAt = SystemClock.elapsedRealtime()
|
||||
val executor = Executors.newSingleThreadExecutor()
|
||||
val config = WebViewStartUpConfig.Builder(executor).build()
|
||||
WebViewCompat.startUpWebView(
|
||||
this,
|
||||
config,
|
||||
object : WebViewOutcomeReceiver<WebViewStartUpResult, WebViewStartupException> {
|
||||
override fun onResult(result: WebViewStartUpResult) {
|
||||
executor.shutdown()
|
||||
Timber.i(
|
||||
"WebView startup completed asynchronously in %d ms.",
|
||||
SystemClock.elapsedRealtime() - startedAt,
|
||||
)
|
||||
}
|
||||
|
||||
override fun onError(error: WebViewStartupException) {
|
||||
executor.shutdown()
|
||||
Timber.w(error, "WebView asynchronous startup failed.")
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
override fun onTerminate() {
|
||||
_context = null
|
||||
super.onTerminate()
|
||||
}
|
||||
|
||||
companion object {
|
||||
@SuppressLint("StaticFieldLeak")
|
||||
private var _context: Context? = null
|
||||
|
||||
fun context() = requireNotNull(_context)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
package app.affine.pro
|
||||
|
||||
import android.content.Context
|
||||
import android.util.AttributeSet
|
||||
import android.view.inputmethod.EditorInfo
|
||||
import android.view.inputmethod.InputConnection
|
||||
import android.view.inputmethod.InputMethodManager
|
||||
import com.getcapacitor.CapacitorWebView
|
||||
|
||||
class AffineEditorWebView(
|
||||
context: Context,
|
||||
attrs: AttributeSet,
|
||||
) : CapacitorWebView(context, attrs) {
|
||||
private val imeState = AndroidImeState()
|
||||
private var imeBridgeInstalled = false
|
||||
@Volatile
|
||||
private var isTrustedPage = false
|
||||
|
||||
private val imeBridge = AffineImeBridge(
|
||||
isTrustedPage = { this.isTrustedPage },
|
||||
clearComposingState = { imeState.nextClearRequestGeneration() },
|
||||
requestRestartInput = ::requestRestartInput,
|
||||
onEditorFocusedChanged = { focused -> imeState.editorFocused = focused },
|
||||
)
|
||||
|
||||
fun updateAndroidIMEBridge(url: String?, expectedOrigin: String?) {
|
||||
val shouldInstallBridge = isTrustedAffineOrigin(url, expectedOrigin)
|
||||
if (shouldInstallBridge == imeBridgeInstalled) {
|
||||
isTrustedPage = shouldInstallBridge
|
||||
return
|
||||
}
|
||||
|
||||
if (shouldInstallBridge) {
|
||||
addJavascriptInterface(imeBridge, AFFINE_IME_BRIDGE_NAME)
|
||||
imeBridgeInstalled = true
|
||||
isTrustedPage = true
|
||||
} else {
|
||||
isTrustedPage = false
|
||||
imeBridgeInstalled = false
|
||||
removeJavascriptInterface(AFFINE_IME_BRIDGE_NAME)
|
||||
imeState.editorFocused = false
|
||||
}
|
||||
}
|
||||
|
||||
override fun onCreateInputConnection(outAttrs: EditorInfo): InputConnection? {
|
||||
val connection = super.onCreateInputConnection(outAttrs) ?: return null
|
||||
return AffineInputConnection(
|
||||
connection,
|
||||
imeState,
|
||||
dispatchDeleteBackward = {
|
||||
dispatchAndroidEditorInput(this, AndroidImeInputType.BACKWARD_DELETE)
|
||||
},
|
||||
dispatchDeleteForward = {
|
||||
dispatchAndroidEditorInput(this, AndroidImeInputType.FORWARD_DELETE)
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
private fun requestRestartInput(delayMs: Long) {
|
||||
val restartGeneration = imeState.nextRestartGeneration()
|
||||
if (delayMs <= 0L) {
|
||||
post { restartInput() }
|
||||
return
|
||||
}
|
||||
|
||||
postDelayed(
|
||||
{
|
||||
if (restartGeneration != imeState.restartInputGeneration) return@postDelayed
|
||||
restartInput()
|
||||
},
|
||||
delayMs,
|
||||
)
|
||||
}
|
||||
|
||||
private fun restartInput() {
|
||||
val inputMethodManager =
|
||||
context.getSystemService(Context.INPUT_METHOD_SERVICE) as? InputMethodManager
|
||||
inputMethodManager?.restartInput(this)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
package app.affine.pro
|
||||
|
||||
import android.net.Uri
|
||||
import android.webkit.JavascriptInterface
|
||||
|
||||
internal const val AFFINE_IME_BRIDGE_NAME = "AffineAndroidIME"
|
||||
internal const val AFFINE_IME_BRIDGE_PROTOCOL_VERSION = 1
|
||||
internal const val DELETE_RESTART_INPUT_DEBOUNCE_MS = 120L
|
||||
|
||||
internal fun normalizeAffineOrigin(url: String?): String? {
|
||||
val uri = Uri.parse(url ?: return null)
|
||||
val scheme = uri.scheme?.lowercase() ?: return null
|
||||
val host = uri.host?.lowercase() ?: return null
|
||||
val isSupportedOrigin =
|
||||
(scheme == "https" && host == "localhost") ||
|
||||
(BuildConfig.DEBUG &&
|
||||
scheme == "http" &&
|
||||
host in setOf("localhost", "127.0.0.1", "10.0.2.2"))
|
||||
if (!isSupportedOrigin) return null
|
||||
|
||||
val port = when {
|
||||
uri.port == -1 -> ""
|
||||
scheme == "https" && uri.port == 443 -> ""
|
||||
scheme == "http" && uri.port == 80 -> ""
|
||||
else -> ":${uri.port}"
|
||||
}
|
||||
return "$scheme://$host$port"
|
||||
}
|
||||
|
||||
internal fun isTrustedAffineOrigin(url: String?, expectedOrigin: String?): Boolean {
|
||||
return expectedOrigin != null && normalizeAffineOrigin(url) == expectedOrigin
|
||||
}
|
||||
|
||||
internal class AffineImeBridge(
|
||||
private val isTrustedPage: () -> Boolean,
|
||||
private val clearComposingState: () -> Unit,
|
||||
private val requestRestartInput: (Long) -> Unit,
|
||||
private val onEditorFocusedChanged: (Boolean) -> Unit,
|
||||
) {
|
||||
@JavascriptInterface
|
||||
fun getProtocolVersion(): Int {
|
||||
return if (isTrustedPage()) AFFINE_IME_BRIDGE_PROTOCOL_VERSION else 0
|
||||
}
|
||||
|
||||
@JavascriptInterface
|
||||
fun finishComposingSession() {
|
||||
if (!isTrustedPage()) return
|
||||
clearComposingState()
|
||||
requestRestartInput(0L)
|
||||
}
|
||||
|
||||
@JavascriptInterface
|
||||
fun finishDeleteSession() {
|
||||
if (!isTrustedPage()) return
|
||||
requestRestartInput(DELETE_RESTART_INPUT_DEBOUNCE_MS)
|
||||
}
|
||||
|
||||
@JavascriptInterface
|
||||
fun setEditorFocused(focused: Boolean) {
|
||||
if (isTrustedPage()) {
|
||||
onEditorFocusedChanged(focused)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
package app.affine.pro
|
||||
|
||||
import android.webkit.WebView
|
||||
|
||||
internal enum class AndroidImeInputType(
|
||||
val value: String,
|
||||
val key: String,
|
||||
val keyCode: Int,
|
||||
) {
|
||||
BACKWARD_DELETE("deleteContentBackward", "Backspace", 8),
|
||||
FORWARD_DELETE("deleteContentForward", "Delete", 46),
|
||||
}
|
||||
|
||||
internal fun dispatchAndroidEditorInput(
|
||||
webView: WebView,
|
||||
inputType: AndroidImeInputType,
|
||||
) {
|
||||
webView.post {
|
||||
webView.evaluateJavascript(
|
||||
"""
|
||||
(() => {
|
||||
try {
|
||||
const selection = document.getSelection();
|
||||
let target = selection?.anchorNode ?? document.activeElement ?? document.body;
|
||||
if (target && target.nodeType === Node.TEXT_NODE) {
|
||||
target = target.parentElement;
|
||||
}
|
||||
if (!(target instanceof EventTarget)) {
|
||||
target = document.activeElement ?? document.body;
|
||||
}
|
||||
const detail = {
|
||||
inputType: '${inputType.value}',
|
||||
handled: false,
|
||||
};
|
||||
const event = new CustomEvent('affine-android-ime-input', {
|
||||
detail,
|
||||
bubbles: true,
|
||||
cancelable: true,
|
||||
composed: true,
|
||||
});
|
||||
const dispatched = target.dispatchEvent(event);
|
||||
const handled = detail.handled || !dispatched;
|
||||
let fallbackKey = null;
|
||||
if (!handled) {
|
||||
fallbackKey = '${inputType.key}';
|
||||
target.dispatchEvent(new KeyboardEvent('keydown', {
|
||||
key: '${inputType.key}',
|
||||
code: '${inputType.key}',
|
||||
keyCode: ${inputType.keyCode},
|
||||
which: ${inputType.keyCode},
|
||||
bubbles: true,
|
||||
cancelable: true,
|
||||
composed: true,
|
||||
}));
|
||||
}
|
||||
return {
|
||||
inputType: '${inputType.value}',
|
||||
handled,
|
||||
fallbackKey,
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('[AffineIME] dispatch editor input failed', error);
|
||||
return { error: String(error) };
|
||||
}
|
||||
})();
|
||||
""".trimIndent(),
|
||||
null,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,332 @@
|
||||
package app.affine.pro
|
||||
|
||||
import android.view.KeyEvent
|
||||
import android.view.inputmethod.ExtractedTextRequest
|
||||
import android.view.inputmethod.InputConnection
|
||||
import android.view.inputmethod.InputConnectionWrapper
|
||||
|
||||
internal class AffineInputConnection(
|
||||
target: InputConnection,
|
||||
private val state: AndroidImeState,
|
||||
private val dispatchDeleteBackward: () -> Unit,
|
||||
private val dispatchDeleteForward: () -> Unit,
|
||||
) : InputConnectionWrapper(target, true) {
|
||||
private var handledClearRequestGeneration = state.clearRequestGeneration
|
||||
private var composingText = ""
|
||||
private var isComposingTextActive = false
|
||||
private var isConsumingDeleteKeyEvent = false
|
||||
|
||||
private val replay = ImeReplayController(
|
||||
deleteBefore = { length -> super.deleteSurroundingText(length, 0) },
|
||||
recordDeleteIntent = { recordDeleteIntent() },
|
||||
hasRecentDeleteIntent = { currentTime -> hasRecentDeleteIntent(currentTime) },
|
||||
)
|
||||
|
||||
override fun setComposingRegion(start: Int, end: Int): Boolean {
|
||||
consumeClearRequest()
|
||||
val regionText = getTextForRegion(start, end)
|
||||
|
||||
val nextComposingText = replay.updateComposingRegion(
|
||||
regionText,
|
||||
composingText,
|
||||
isComposingTextActive,
|
||||
)
|
||||
if (nextComposingText != null) {
|
||||
composingText = nextComposingText
|
||||
isComposingTextActive = nextComposingText.isNotEmpty()
|
||||
}
|
||||
|
||||
// Keep the native composing region untouched so IME autocorrect replay stays in the
|
||||
// explicit replay state machine instead of being applied twice by the platform.
|
||||
return true
|
||||
}
|
||||
|
||||
override fun setComposingText(text: CharSequence?, newCursorPosition: Int): Boolean {
|
||||
consumeClearRequest()
|
||||
val nextText = text?.toString() ?: ""
|
||||
|
||||
if (replay.shouldAdoptExternalRegionForReplacement(nextText)) {
|
||||
replay.clearDroppingReplay()
|
||||
}
|
||||
|
||||
if (replay.shouldDropExternalReplay(nextText)) {
|
||||
replay.markDroppingReplay()
|
||||
replay.deleteForShrinkingExternalReplay(nextText.length)
|
||||
return true
|
||||
}
|
||||
|
||||
val adoptedText = replay.adoptExternalRegionAsComposingTextIfNeeded(nextText)
|
||||
if (adoptedText != null) {
|
||||
composingText = adoptedText
|
||||
isComposingTextActive = adoptedText.isNotEmpty()
|
||||
}
|
||||
replay.clearExternalRegion()
|
||||
return applyComposingText(nextText)
|
||||
}
|
||||
|
||||
override fun commitText(text: CharSequence?, newCursorPosition: Int): Boolean {
|
||||
consumeClearRequest()
|
||||
val committedText = text?.toString() ?: ""
|
||||
|
||||
if (
|
||||
replay.shouldDeleteExternalRegionOnEmptyCommit(
|
||||
committedText,
|
||||
isComposingTextActive,
|
||||
)
|
||||
) {
|
||||
replay.deleteRemainingExternalReplayText()
|
||||
replay.clearExternalRegion()
|
||||
return true
|
||||
}
|
||||
|
||||
if (replay.isDroppingReplay) {
|
||||
if (
|
||||
committedText.isEmpty() ||
|
||||
committedText == replay.currentExternalRegionText
|
||||
) {
|
||||
if (committedText.isEmpty()) {
|
||||
replay.deleteRemainingExternalReplayTextAfterShrink()
|
||||
}
|
||||
return true
|
||||
}
|
||||
replay.clearExternalRegion()
|
||||
}
|
||||
|
||||
if (isComposingTextActive && committedText.isNotEmpty()) {
|
||||
if (isWordBoundaryCommit(committedText)) {
|
||||
resetComposingText()
|
||||
replay.clearExternalRegion()
|
||||
return super.commitText(text, newCursorPosition)
|
||||
}
|
||||
|
||||
val result = applyComposingText(committedText)
|
||||
resetComposingText()
|
||||
return result
|
||||
}
|
||||
|
||||
if (committedText.isNotEmpty()) {
|
||||
replay.clearExternalRegion()
|
||||
}
|
||||
|
||||
return super.commitText(text, newCursorPosition)
|
||||
}
|
||||
|
||||
override fun finishComposingText(): Boolean {
|
||||
consumeClearRequest()
|
||||
resetComposingText()
|
||||
replay.clearExternalRegion()
|
||||
return super.finishComposingText()
|
||||
}
|
||||
|
||||
override fun deleteSurroundingText(beforeLength: Int, afterLength: Int): Boolean {
|
||||
consumeClearRequest()
|
||||
recordDeleteIntent(beforeLength, afterLength)
|
||||
if (replay.shouldDropNativeDeleteAfterSyntheticExternalDelete(beforeLength, afterLength)) {
|
||||
replay.clearExternalRegion()
|
||||
return true
|
||||
}
|
||||
replay.clearExternalRegion()
|
||||
if (isComposingTextActive && beforeLength > 0) {
|
||||
trimComposingTail(beforeLength, codePoints = false)
|
||||
}
|
||||
return super.deleteSurroundingText(beforeLength, afterLength)
|
||||
}
|
||||
|
||||
override fun deleteSurroundingTextInCodePoints(
|
||||
beforeLength: Int,
|
||||
afterLength: Int,
|
||||
): Boolean {
|
||||
consumeClearRequest()
|
||||
recordDeleteIntent(beforeLength, afterLength)
|
||||
if (replay.shouldDropNativeDeleteAfterSyntheticExternalDelete(beforeLength, afterLength)) {
|
||||
replay.clearExternalRegion()
|
||||
return true
|
||||
}
|
||||
replay.clearExternalRegion()
|
||||
if (isComposingTextActive && beforeLength > 0) {
|
||||
trimComposingTail(beforeLength, codePoints = true)
|
||||
}
|
||||
return super.deleteSurroundingTextInCodePoints(beforeLength, afterLength)
|
||||
}
|
||||
|
||||
override fun setSelection(start: Int, end: Int): Boolean {
|
||||
consumeClearRequest()
|
||||
replay.clearExternalRegion()
|
||||
resetComposingText()
|
||||
return super.setSelection(start, end)
|
||||
}
|
||||
|
||||
override fun performEditorAction(editorAction: Int): Boolean {
|
||||
consumeClearRequest()
|
||||
resetComposingText()
|
||||
replay.clearExternalRegion()
|
||||
return super.performEditorAction(editorAction)
|
||||
}
|
||||
|
||||
override fun sendKeyEvent(event: KeyEvent): Boolean {
|
||||
val isDeleteActionDown =
|
||||
event.action == KeyEvent.ACTION_DOWN &&
|
||||
(event.keyCode == KeyEvent.KEYCODE_DEL ||
|
||||
event.keyCode == KeyEvent.KEYCODE_FORWARD_DEL)
|
||||
consumeClearRequest(skipNativeFinish = isDeleteActionDown)
|
||||
|
||||
if (event.keyCode == KeyEvent.KEYCODE_DEL) {
|
||||
if (!state.editorFocused && !isConsumingDeleteKeyEvent) {
|
||||
return super.sendKeyEvent(event)
|
||||
}
|
||||
if (event.action == KeyEvent.ACTION_DOWN) {
|
||||
recordDeleteIntent()
|
||||
dispatchDeleteBackward()
|
||||
isConsumingDeleteKeyEvent = true
|
||||
return true
|
||||
}
|
||||
if (event.action == KeyEvent.ACTION_UP && isConsumingDeleteKeyEvent) {
|
||||
isConsumingDeleteKeyEvent = false
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
if (event.keyCode == KeyEvent.KEYCODE_FORWARD_DEL) {
|
||||
if (!state.editorFocused && !isConsumingDeleteKeyEvent) {
|
||||
return super.sendKeyEvent(event)
|
||||
}
|
||||
if (event.action == KeyEvent.ACTION_DOWN) {
|
||||
recordDeleteIntent()
|
||||
dispatchDeleteForward()
|
||||
isConsumingDeleteKeyEvent = true
|
||||
return true
|
||||
}
|
||||
if (event.action == KeyEvent.ACTION_UP && isConsumingDeleteKeyEvent) {
|
||||
isConsumingDeleteKeyEvent = false
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
if (
|
||||
event.action == KeyEvent.ACTION_DOWN &&
|
||||
(event.keyCode == KeyEvent.KEYCODE_SPACE ||
|
||||
event.keyCode == KeyEvent.KEYCODE_ENTER)
|
||||
) {
|
||||
resetComposingText()
|
||||
replay.clearExternalRegion()
|
||||
}
|
||||
return super.sendKeyEvent(event)
|
||||
}
|
||||
|
||||
private fun applyComposingText(nextText: String): Boolean {
|
||||
val previousText = composingText
|
||||
val prefixLength = commonPrefixLength(previousText, nextText)
|
||||
val deleteCount = previousText.length - prefixLength
|
||||
val insertText = nextText.substring(prefixLength)
|
||||
|
||||
if (deleteCount > 0) {
|
||||
super.deleteSurroundingText(deleteCount, 0)
|
||||
}
|
||||
if (insertText.isNotEmpty()) {
|
||||
super.commitText(insertText, 1)
|
||||
}
|
||||
|
||||
composingText = nextText
|
||||
isComposingTextActive = nextText.isNotEmpty()
|
||||
return true
|
||||
}
|
||||
|
||||
private fun trimComposingTail(beforeLength: Int, codePoints: Boolean) {
|
||||
val length =
|
||||
if (codePoints) {
|
||||
beforeLength.coerceAtMost(composingText.codePointCount(0, composingText.length))
|
||||
} else {
|
||||
beforeLength.coerceAtMost(composingText.length)
|
||||
}
|
||||
composingText =
|
||||
if (codePoints) {
|
||||
val end = composingText.offsetByCodePoints(composingText.length, -length)
|
||||
composingText.substring(0, end)
|
||||
} else {
|
||||
composingText.dropLast(length)
|
||||
}
|
||||
if (composingText.isEmpty()) {
|
||||
resetComposingText()
|
||||
}
|
||||
}
|
||||
|
||||
private fun resetComposingText() {
|
||||
composingText = ""
|
||||
isComposingTextActive = false
|
||||
}
|
||||
|
||||
private fun consumeClearRequest(skipNativeFinish: Boolean = false) {
|
||||
val clearRequestGeneration = state.clearRequestGeneration
|
||||
if (
|
||||
clearRequestGeneration == 0L ||
|
||||
clearRequestGeneration == handledClearRequestGeneration
|
||||
) {
|
||||
return
|
||||
}
|
||||
|
||||
handledClearRequestGeneration = clearRequestGeneration
|
||||
resetComposingText()
|
||||
replay.clearExternalRegion()
|
||||
if (skipNativeFinish) {
|
||||
return
|
||||
}
|
||||
super.finishComposingText()
|
||||
}
|
||||
|
||||
private fun getTextForRegion(start: Int, end: Int): String {
|
||||
if (start < 0 || end <= start) return ""
|
||||
|
||||
val extractedText =
|
||||
try {
|
||||
getExtractedText(ExtractedTextRequest(), 0)
|
||||
} catch (_: Exception) {
|
||||
null
|
||||
} ?: return ""
|
||||
val text = extractedText.text?.toString().orEmpty()
|
||||
val localStart = start - extractedText.startOffset
|
||||
val localEnd = end - extractedText.startOffset
|
||||
if (localStart < 0 || localEnd > text.length) return ""
|
||||
|
||||
return text.substring(localStart, localEnd)
|
||||
}
|
||||
|
||||
private fun recordDeleteIntent(beforeLength: Int, afterLength: Int) {
|
||||
if (beforeLength <= 0 || afterLength != 0) return
|
||||
recordDeleteIntent()
|
||||
}
|
||||
|
||||
private fun recordDeleteIntent() {
|
||||
state.lastDeleteIntentAtMs = android.os.SystemClock.uptimeMillis()
|
||||
}
|
||||
|
||||
private fun hasRecentDeleteIntent(currentTime: Long): Boolean {
|
||||
return currentTime - state.lastDeleteIntentAtMs <= IME_REPLAY_DELETE_WINDOW_MS
|
||||
}
|
||||
|
||||
private fun isWordBoundaryCommit(text: String): Boolean {
|
||||
return text == " " || text == "\n"
|
||||
}
|
||||
|
||||
private fun commonPrefixLength(left: String, right: String): Int {
|
||||
val maxLength = minOf(left.length, right.length)
|
||||
for (index in 0 until maxLength) {
|
||||
if (left[index] != right[index]) {
|
||||
return snapToCodePointBoundary(left, index)
|
||||
}
|
||||
}
|
||||
return snapToCodePointBoundary(left, maxLength)
|
||||
}
|
||||
|
||||
private fun snapToCodePointBoundary(text: String, index: Int): Int {
|
||||
return if (
|
||||
index > 0 &&
|
||||
index < text.length &&
|
||||
Character.isHighSurrogate(text[index - 1]) &&
|
||||
Character.isLowSurrogate(text[index])
|
||||
) {
|
||||
index - 1
|
||||
} else {
|
||||
index
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
package app.affine.pro
|
||||
|
||||
import android.webkit.WebResourceRequest
|
||||
import android.webkit.WebView
|
||||
import com.getcapacitor.Bridge
|
||||
import com.getcapacitor.BridgeWebViewClient
|
||||
|
||||
internal class AffineWebViewClient(
|
||||
bridge: Bridge,
|
||||
private val trustedOrigin: String?,
|
||||
) : BridgeWebViewClient(bridge) {
|
||||
override fun shouldOverrideUrlLoading(
|
||||
view: WebView,
|
||||
request: WebResourceRequest,
|
||||
): Boolean {
|
||||
val shouldOverride = super.shouldOverrideUrlLoading(view, request)
|
||||
if (!shouldOverride && request.isForMainFrame) {
|
||||
(view as? AffineEditorWebView)?.updateAndroidIMEBridge(
|
||||
request.url.toString(),
|
||||
trustedOrigin,
|
||||
)
|
||||
}
|
||||
return shouldOverride
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
package app.affine.pro
|
||||
|
||||
internal class AndroidImeState {
|
||||
@Volatile
|
||||
var clearRequestGeneration: Long = 0L
|
||||
|
||||
@Volatile
|
||||
var editorFocused: Boolean = false
|
||||
|
||||
@Volatile
|
||||
var lastDeleteIntentAtMs: Long = 0L
|
||||
|
||||
@Volatile
|
||||
var restartInputGeneration: Int = 0
|
||||
|
||||
@Synchronized
|
||||
fun nextRestartGeneration(): Int {
|
||||
restartInputGeneration += 1
|
||||
return restartInputGeneration
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
fun nextClearRequestGeneration(): Long {
|
||||
clearRequestGeneration += 1
|
||||
return clearRequestGeneration
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
package app.affine.pro
|
||||
|
||||
import android.webkit.WebView
|
||||
import app.affine.pro.utils.getCurrentServerBaseUrl
|
||||
import app.affine.pro.utils.logger.FileTree
|
||||
import com.getcapacitor.Bridge
|
||||
import com.getcapacitor.WebViewListener
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.MainScope
|
||||
import kotlinx.coroutines.launch
|
||||
import okhttp3.HttpUrl.Companion.toHttpUrl
|
||||
import timber.log.Timber
|
||||
|
||||
object AuthInitializer {
|
||||
|
||||
fun initialize(bridge: Bridge) {
|
||||
bridge.addWebViewListener(object : WebViewListener() {
|
||||
override fun onPageLoaded(webView: WebView?) {
|
||||
webView?.post { bridge.removeWebViewListener(this) }
|
||||
MainScope().launch(Dispatchers.IO) {
|
||||
try {
|
||||
FileTree.get()?.checkAndUploadOldLogs(
|
||||
bridge.getCurrentServerBaseUrl().toHttpUrl()
|
||||
)
|
||||
} catch (e: Exception) {
|
||||
Timber.w(e, "[init] auth initializer fail.")
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
package app.affine.pro
|
||||
|
||||
import android.content.Context
|
||||
import kotlinx.serialization.Serializable
|
||||
import kotlinx.serialization.json.Json
|
||||
|
||||
object CapacitorConfig {
|
||||
|
||||
@Serializable
|
||||
private data class Config(val affineVersion: String)
|
||||
private val json = Json { ignoreUnknownKeys = true }
|
||||
private lateinit var config: Config
|
||||
|
||||
fun init(context: Context) {
|
||||
val configJson = context.assets.open("capacitor.config.json")
|
||||
.bufferedReader()
|
||||
.readLines()
|
||||
.reduce { acc, s ->
|
||||
acc.trim().plus(s.trim())
|
||||
}
|
||||
config = json.decodeFromString(configJson)
|
||||
}
|
||||
|
||||
fun getAffineVersion() = config.affineVersion
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
package app.affine.pro
|
||||
|
||||
enum class Prompt(val value: String) {
|
||||
Summary("Summary"),
|
||||
ExplainThis("Explain this"),
|
||||
WriteAnArticleAboutThis("Write an article about this"),
|
||||
WriteATwitterAboutThis("Write a twitter about this"),
|
||||
WriteAPoemAboutThis("Write a poem about this"),
|
||||
WriteABlogPostAboutThis("Write a blog post about this"),
|
||||
WriteOutline("Write outline"),
|
||||
ChangeToneTo("Change tone to"),
|
||||
ImproveWritingForIt("Improve writing for it"),
|
||||
ImproveGrammarForIt("Improve grammar for it"),
|
||||
FixSpellingForIt("Fix spelling for it"),
|
||||
CreateHeadings("Create headings"),
|
||||
MakeItLonger("Make it longer"),
|
||||
MakeItShorter("Make it shorter"),
|
||||
ContinueWriting("Continue writing"),
|
||||
ChatWithAFFiNEAI("Chat With AFFiNE AI"),
|
||||
}
|
||||
@@ -0,0 +1,217 @@
|
||||
package app.affine.pro
|
||||
|
||||
import android.os.SystemClock
|
||||
|
||||
internal const val IME_REPLAY_DELETE_WINDOW_MS = 500L
|
||||
|
||||
internal class ImeReplayController(
|
||||
private val deleteBefore: (Int) -> Unit,
|
||||
private val recordDeleteIntent: () -> Unit,
|
||||
private val hasRecentDeleteIntent: (Long) -> Boolean,
|
||||
private val now: () -> Long = SystemClock::uptimeMillis,
|
||||
) {
|
||||
private var externalRegionText = ""
|
||||
private var externalRegionAtMs = 0L
|
||||
private var isDroppingExternalReplay = false
|
||||
private var lastExternalReplayTextLength = -1
|
||||
private var lastExternalReplayTextAtMs = 0L
|
||||
private var lastExternalReplayDeletedLength = 0
|
||||
private var syntheticExternalDeleteAtMs = 0L
|
||||
|
||||
val currentExternalRegionText: String
|
||||
get() = externalRegionText
|
||||
|
||||
val isDroppingReplay: Boolean
|
||||
get() = isDroppingExternalReplay
|
||||
|
||||
fun updateComposingRegion(
|
||||
regionText: String,
|
||||
composingText: String,
|
||||
isComposingTextActive: Boolean,
|
||||
): String? {
|
||||
if (!isComposingTextActive) {
|
||||
externalRegionText = regionText
|
||||
externalRegionAtMs = now()
|
||||
isDroppingExternalReplay = false
|
||||
lastExternalReplayTextLength = regionText.length
|
||||
lastExternalReplayTextAtMs = externalRegionAtMs
|
||||
lastExternalReplayDeletedLength = 0
|
||||
return null
|
||||
}
|
||||
|
||||
if (regionText == composingText) return null
|
||||
|
||||
clearExternalRegion()
|
||||
return regionText
|
||||
}
|
||||
|
||||
fun shouldAdoptExternalRegionForReplacement(nextText: String): Boolean {
|
||||
if (nextText.isEmpty() || externalRegionText.isEmpty()) return false
|
||||
if (externalRegionText.startsWith(nextText)) return false
|
||||
|
||||
val commonPrefixLength = commonPrefixLength(externalRegionText, nextText)
|
||||
val minPrefixLength = minOf(
|
||||
MIN_REPLACEMENT_COMMON_PREFIX_LENGTH,
|
||||
externalRegionText.length,
|
||||
nextText.length,
|
||||
)
|
||||
|
||||
return commonPrefixLength >= minPrefixLength &&
|
||||
nextText.length >= externalRegionText.length
|
||||
}
|
||||
|
||||
fun shouldDropExternalReplay(nextText: String): Boolean {
|
||||
if (nextText.isEmpty()) return false
|
||||
if (isDroppingExternalReplay) {
|
||||
return !shouldAdoptExternalRegionForReplacement(nextText)
|
||||
}
|
||||
if (externalRegionText.isEmpty()) return false
|
||||
|
||||
val currentTime = now()
|
||||
if (
|
||||
currentTime - externalRegionAtMs > EXTERNAL_REGION_REPLAY_WINDOW_MS &&
|
||||
!shouldAdoptExternalRegionForReplacement(nextText)
|
||||
) {
|
||||
clearExternalRegion()
|
||||
return false
|
||||
}
|
||||
|
||||
val isSameRegionReplay =
|
||||
externalRegionText == nextText && hasRecentDeleteIntent(currentTime)
|
||||
val isDeleteShrinkReplay =
|
||||
externalRegionText.startsWith(nextText) && hasRecentDeleteIntent(currentTime)
|
||||
val isLikelyPassiveReplay = nextText.length > 1 || externalRegionText.length > 1
|
||||
|
||||
return (isSameRegionReplay || isDeleteShrinkReplay) && isLikelyPassiveReplay
|
||||
}
|
||||
|
||||
fun markDroppingReplay() {
|
||||
isDroppingExternalReplay = true
|
||||
}
|
||||
|
||||
fun clearDroppingReplay() {
|
||||
isDroppingExternalReplay = false
|
||||
}
|
||||
|
||||
fun adoptExternalRegionAsComposingTextIfNeeded(nextText: String): String? {
|
||||
if (externalRegionText.isEmpty()) return null
|
||||
|
||||
val currentTime = now()
|
||||
if (
|
||||
currentTime - externalRegionAtMs > EXTERNAL_REGION_REPLAY_WINDOW_MS &&
|
||||
!shouldAdoptExternalRegionForReplacement(nextText)
|
||||
) {
|
||||
return null
|
||||
}
|
||||
|
||||
return externalRegionText
|
||||
}
|
||||
|
||||
fun clearExternalRegion() {
|
||||
externalRegionText = ""
|
||||
externalRegionAtMs = 0L
|
||||
isDroppingExternalReplay = false
|
||||
lastExternalReplayTextLength = -1
|
||||
lastExternalReplayTextAtMs = 0L
|
||||
lastExternalReplayDeletedLength = 0
|
||||
}
|
||||
|
||||
fun deleteForShrinkingExternalReplay(nextTextLength: Int) {
|
||||
val currentTime = now()
|
||||
val deleteCount =
|
||||
if (
|
||||
lastExternalReplayTextLength > 0 &&
|
||||
nextTextLength < lastExternalReplayTextLength &&
|
||||
currentTime - lastExternalReplayTextAtMs <= IME_REPLAY_DELETE_WINDOW_MS
|
||||
) {
|
||||
lastExternalReplayTextLength - nextTextLength
|
||||
} else {
|
||||
0
|
||||
}
|
||||
|
||||
if (deleteCount > 0) {
|
||||
recordDeleteIntent()
|
||||
deleteBefore(deleteCount)
|
||||
lastExternalReplayDeletedLength += deleteCount
|
||||
syntheticExternalDeleteAtMs = now()
|
||||
}
|
||||
|
||||
lastExternalReplayTextLength = nextTextLength
|
||||
lastExternalReplayTextAtMs = currentTime
|
||||
}
|
||||
|
||||
fun deleteRemainingExternalReplayText() {
|
||||
val currentTime = now()
|
||||
if (
|
||||
lastExternalReplayTextLength <= 0 ||
|
||||
currentTime - lastExternalReplayTextAtMs > IME_REPLAY_DELETE_WINDOW_MS
|
||||
) {
|
||||
return
|
||||
}
|
||||
|
||||
recordDeleteIntent()
|
||||
deleteBefore(lastExternalReplayTextLength)
|
||||
syntheticExternalDeleteAtMs = now()
|
||||
lastExternalReplayTextLength = 0
|
||||
lastExternalReplayTextAtMs = currentTime
|
||||
}
|
||||
|
||||
fun deleteRemainingExternalReplayTextAfterShrink() {
|
||||
if (lastExternalReplayDeletedLength <= 0) return
|
||||
deleteRemainingExternalReplayText()
|
||||
}
|
||||
|
||||
fun shouldDropNativeDeleteAfterSyntheticExternalDelete(
|
||||
beforeLength: Int,
|
||||
afterLength: Int,
|
||||
): Boolean {
|
||||
val currentTime = now()
|
||||
return beforeLength > 0 &&
|
||||
afterLength == 0 &&
|
||||
currentTime - syntheticExternalDeleteAtMs <=
|
||||
SYNTHETIC_EXTERNAL_DELETE_SUPPRESS_WINDOW_MS
|
||||
}
|
||||
|
||||
fun shouldDeleteExternalRegionOnEmptyCommit(
|
||||
committedText: String,
|
||||
isComposingTextActive: Boolean,
|
||||
): Boolean {
|
||||
if (committedText.isNotEmpty()) return false
|
||||
if (isComposingTextActive || externalRegionText.isEmpty()) return false
|
||||
if (lastExternalReplayTextLength <= 0) return false
|
||||
if (lastExternalReplayDeletedLength <= 0) return false
|
||||
|
||||
val currentTime = now()
|
||||
return currentTime - externalRegionAtMs <= IME_REPLAY_DELETE_WINDOW_MS &&
|
||||
hasRecentDeleteIntent(currentTime)
|
||||
}
|
||||
|
||||
private fun commonPrefixLength(left: String, right: String): Int {
|
||||
val maxLength = minOf(left.length, right.length)
|
||||
for (index in 0 until maxLength) {
|
||||
if (left[index] != right[index]) {
|
||||
return snapToCodePointBoundary(left, index)
|
||||
}
|
||||
}
|
||||
return snapToCodePointBoundary(left, maxLength)
|
||||
}
|
||||
|
||||
private fun snapToCodePointBoundary(text: String, index: Int): Int {
|
||||
return if (
|
||||
index > 0 &&
|
||||
index < text.length &&
|
||||
Character.isHighSurrogate(text[index - 1]) &&
|
||||
Character.isLowSurrogate(text[index])
|
||||
) {
|
||||
index - 1
|
||||
} else {
|
||||
index
|
||||
}
|
||||
}
|
||||
|
||||
private companion object {
|
||||
const val EXTERNAL_REGION_REPLAY_WINDOW_MS = 500L
|
||||
const val SYNTHETIC_EXTERNAL_DELETE_SUPPRESS_WINDOW_MS = 120L
|
||||
const val MIN_REPLACEMENT_COMMON_PREFIX_LENGTH = 2
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,194 @@
|
||||
package app.affine.pro
|
||||
|
||||
import android.content.res.ColorStateList
|
||||
import android.content.ComponentCallbacks2
|
||||
import android.os.Bundle
|
||||
import android.view.Gravity
|
||||
import android.view.View
|
||||
import android.webkit.WebSettings
|
||||
import android.webkit.WebView
|
||||
import androidx.activity.enableEdgeToEdge
|
||||
import androidx.coordinatorlayout.widget.CoordinatorLayout
|
||||
import androidx.core.content.ContextCompat
|
||||
import androidx.core.graphics.drawable.DrawableCompat
|
||||
import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen
|
||||
import androidx.core.view.ViewCompat
|
||||
import androidx.core.view.WindowInsetsCompat
|
||||
import androidx.core.view.updateMargins
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import androidx.vectordrawable.graphics.drawable.VectorDrawableCompat
|
||||
import app.affine.pro.ai.AIActivity
|
||||
import app.affine.pro.plugin.AIButtonPlugin
|
||||
import app.affine.pro.plugin.AFFiNEThemePlugin
|
||||
import app.affine.pro.plugin.AuthPlugin
|
||||
import app.affine.pro.plugin.HashCashPlugin
|
||||
import app.affine.pro.plugin.NbStorePlugin
|
||||
import app.affine.pro.plugin.MobileBackPlugin
|
||||
import app.affine.pro.plugin.PreviewPlugin
|
||||
import app.affine.pro.service.GraphQLService
|
||||
import app.affine.pro.service.SSEService
|
||||
import app.affine.pro.service.WebService
|
||||
import app.affine.pro.utils.px2dp
|
||||
import app.affine.pro.utils.dp2px
|
||||
import com.getcapacitor.BridgeActivity
|
||||
import com.getcapacitor.WebViewListener
|
||||
import com.google.android.material.floatingactionbutton.FloatingActionButton
|
||||
import dagger.hilt.android.AndroidEntryPoint
|
||||
import kotlinx.coroutines.launch
|
||||
import javax.inject.Inject
|
||||
|
||||
|
||||
@AndroidEntryPoint
|
||||
class MainActivity : BridgeActivity(), AIButtonPlugin.Callback, AFFiNEThemePlugin.Callback,
|
||||
View.OnClickListener {
|
||||
|
||||
@Inject
|
||||
lateinit var webService: WebService
|
||||
|
||||
@Inject
|
||||
lateinit var sseService: SSEService
|
||||
|
||||
@Inject
|
||||
lateinit var graphQLService: GraphQLService
|
||||
|
||||
init {
|
||||
registerPlugins(
|
||||
listOf(
|
||||
AFFiNEThemePlugin::class.java,
|
||||
AIButtonPlugin::class.java,
|
||||
AuthPlugin::class.java,
|
||||
HashCashPlugin::class.java,
|
||||
NbStorePlugin::class.java,
|
||||
MobileBackPlugin::class.java,
|
||||
PreviewPlugin::class.java,
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
private val fab: FloatingActionButton by lazy {
|
||||
FloatingActionButton(this).apply {
|
||||
visibility = View.GONE
|
||||
layoutParams = CoordinatorLayout.LayoutParams(dp2px(52), dp2px(52)).apply {
|
||||
gravity = Gravity.END or Gravity.BOTTOM
|
||||
updateMargins(0, 0, dp2px(24), dp2px(86))
|
||||
}
|
||||
customSize = dp2px(52)
|
||||
setImageResource(R.drawable.ic_ai)
|
||||
setImageDrawable(
|
||||
VectorDrawableCompat.create(resources, R.drawable.ic_ai, theme)?.apply {
|
||||
DrawableCompat.setTint(
|
||||
this,
|
||||
ContextCompat.getColor(context, R.color.affine_primary)
|
||||
)
|
||||
})
|
||||
setOnClickListener(this@MainActivity)
|
||||
val parent = bridge.webView.parent as CoordinatorLayout
|
||||
parent.addView(this)
|
||||
}
|
||||
}
|
||||
|
||||
private var navHeight = 0
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
installSplashScreen()
|
||||
enableEdgeToEdge()
|
||||
super.onCreate(savedInstanceState)
|
||||
ViewCompat.setOnApplyWindowInsetsListener(window.decorView) { v, insets ->
|
||||
navHeight = px2dp(insets.getInsets(WindowInsetsCompat.Type.navigationBars()).bottom)
|
||||
ViewCompat.onApplyWindowInsets(v, insets)
|
||||
}
|
||||
}
|
||||
|
||||
override fun load() {
|
||||
super.load()
|
||||
configureAndroidIMEBridge()
|
||||
AuthInitializer.initialize(bridge)
|
||||
configureEditorWebView()
|
||||
}
|
||||
|
||||
private fun configureAndroidIMEBridge() {
|
||||
val trustedOrigin = normalizeAffineOrigin(bridge.localUrl)
|
||||
bridge.setWebViewClient(AffineWebViewClient(bridge, trustedOrigin))
|
||||
bridge.addWebViewListener(object : WebViewListener() {
|
||||
override fun onPageCommitVisible(view: WebView?, url: String?) {
|
||||
(view as? AffineEditorWebView)?.updateAndroidIMEBridge(url, trustedOrigin)
|
||||
}
|
||||
})
|
||||
|
||||
(bridge.webView as? AffineEditorWebView)?.updateAndroidIMEBridge(
|
||||
bridge.webView.url ?: bridge.localUrl,
|
||||
trustedOrigin,
|
||||
)
|
||||
}
|
||||
|
||||
override fun onTrimMemory(level: Int) {
|
||||
super.onTrimMemory(level)
|
||||
if (level >= ComponentCallbacks2.TRIM_MEMORY_UI_HIDDEN) {
|
||||
bridge.webView.evaluateJavascript(
|
||||
"window.dispatchEvent(new Event('affine:memory-pressure'))",
|
||||
null,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun configureEditorWebView() {
|
||||
bridge.webView.apply {
|
||||
overScrollMode = View.OVER_SCROLL_NEVER
|
||||
isHorizontalScrollBarEnabled = false
|
||||
isVerticalScrollBarEnabled = false
|
||||
settings.apply {
|
||||
// Debug builds may point CAP_SERVER_URL at an HTTP dev server; release builds
|
||||
// should keep mixed content blocked.
|
||||
mixedContentMode = if (BuildConfig.DEBUG) {
|
||||
WebSettings.MIXED_CONTENT_COMPATIBILITY_MODE
|
||||
} else {
|
||||
WebSettings.MIXED_CONTENT_NEVER_ALLOW
|
||||
}
|
||||
setSupportZoom(false)
|
||||
builtInZoomControls = false
|
||||
displayZoomControls = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun present() {
|
||||
lifecycleScope.launch {
|
||||
fab.show()
|
||||
}
|
||||
}
|
||||
|
||||
override fun dismiss() {
|
||||
lifecycleScope.launch {
|
||||
fab.hide()
|
||||
}
|
||||
}
|
||||
|
||||
override fun onThemeChanged(darkMode: Boolean) {
|
||||
lifecycleScope.launch {
|
||||
fab.backgroundTintList = ColorStateList.valueOf(
|
||||
ContextCompat.getColor(
|
||||
this@MainActivity,
|
||||
if (darkMode) {
|
||||
R.color.layer_background_primary_dark
|
||||
} else {
|
||||
R.color.layer_background_primary
|
||||
}
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
override fun getSystemNavBarHeight(): Int {
|
||||
return navHeight
|
||||
}
|
||||
|
||||
override fun onClick(v: View) {
|
||||
lifecycleScope.launch {
|
||||
webService.update(bridge)
|
||||
sseService.updateServer(bridge)
|
||||
graphQLService.updateServer(bridge)
|
||||
AIActivity.open(this@MainActivity)
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,153 @@
|
||||
package app.affine.pro.ai
|
||||
|
||||
import android.content.Intent
|
||||
import android.os.Bundle
|
||||
import androidx.activity.compose.setContent
|
||||
import androidx.activity.enableEdgeToEdge
|
||||
import androidx.activity.viewModels
|
||||
import androidx.appcompat.app.AppCompatActivity
|
||||
import androidx.appcompat.app.AppCompatDelegate
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.WindowInsets
|
||||
import androidx.compose.foundation.layout.exclude
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.ime
|
||||
import androidx.compose.foundation.layout.imePadding
|
||||
import androidx.compose.foundation.layout.navigationBars
|
||||
import androidx.compose.foundation.layout.navigationBarsPadding
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.ScaffoldDefaults
|
||||
import androidx.compose.material3.TopAppBarDefaults
|
||||
import androidx.compose.material3.rememberTopAppBarState
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.SideEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableIntStateOf
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.input.nestedscroll.nestedScroll
|
||||
import androidx.core.view.ViewCompat
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import app.affine.pro.ai.chat.ChatViewModel
|
||||
import app.affine.pro.ai.chat.ui.ChatAppBar
|
||||
import app.affine.pro.ai.chat.ui.Message
|
||||
import app.affine.pro.ai.chat.ui.UserInput
|
||||
import app.affine.pro.theme.AFFiNETheme
|
||||
import app.affine.pro.theme.ThemeMode
|
||||
import dagger.hilt.android.AndroidEntryPoint
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@AndroidEntryPoint
|
||||
class AIActivity : AppCompatActivity() {
|
||||
|
||||
private val viewModel by viewModels<ChatViewModel>()
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
enableEdgeToEdge()
|
||||
delegate.localNightMode = AppCompatDelegate.MODE_NIGHT_YES
|
||||
super.onCreate(savedInstanceState)
|
||||
ViewCompat.setOnApplyWindowInsetsListener(window.decorView) { v, insets ->
|
||||
ViewCompat.onApplyWindowInsets(v, insets)
|
||||
}
|
||||
setContent {
|
||||
val scope = rememberCoroutineScope()
|
||||
val scrollState = rememberLazyListState()
|
||||
val topBarState = rememberTopAppBarState()
|
||||
val scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior(topBarState)
|
||||
AFFiNETheme(mode = ThemeMode.Dark) {
|
||||
Scaffold(
|
||||
topBar = {
|
||||
ChatAppBar(
|
||||
scrollBehavior = scrollBehavior,
|
||||
onBackClick = { finish() },
|
||||
)
|
||||
},
|
||||
contentWindowInsets = ScaffoldDefaults
|
||||
.contentWindowInsets
|
||||
.exclude(WindowInsets.navigationBars)
|
||||
.exclude(WindowInsets.ime),
|
||||
modifier = Modifier.nestedScroll(scrollBehavior.nestedScrollConnection),
|
||||
containerColor = AFFiNETheme.colors.backgroundPrimary,
|
||||
) { paddingValues ->
|
||||
val messageUiState by viewModel.messagesUiState.collectAsStateWithLifecycle()
|
||||
val sendBtnEnable by viewModel.sendBtnUiState.collectAsStateWithLifecycle()
|
||||
|
||||
var previousMessageCount by remember { mutableIntStateOf(0) }
|
||||
var isAtBottom by remember { mutableStateOf(true) }
|
||||
|
||||
LaunchedEffect(messageUiState.messages) {
|
||||
val currentMessageCount = messageUiState.messages.size
|
||||
if (isAtBottom && currentMessageCount >= previousMessageCount) {
|
||||
scrollState.animateScrollToItem(0)
|
||||
}
|
||||
previousMessageCount = currentMessageCount
|
||||
}
|
||||
|
||||
SideEffect {
|
||||
isAtBottom = messageUiState.messages.isEmpty()
|
||||
|| scrollState.firstVisibleItemIndex == 0
|
||||
&& scrollState.firstVisibleItemScrollOffset == 0
|
||||
}
|
||||
|
||||
Column(
|
||||
Modifier
|
||||
.fillMaxSize()
|
||||
.padding(paddingValues)
|
||||
) {
|
||||
Box(Modifier.weight(1f)) {
|
||||
with(messageUiState) {
|
||||
LazyColumn(
|
||||
reverseLayout = true,
|
||||
state = scrollState,
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
) {
|
||||
items(
|
||||
items = messages,
|
||||
key = { it.id ?: "" },
|
||||
contentType = { it.role }
|
||||
) { message ->
|
||||
Message(message)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
UserInput(
|
||||
modifier = Modifier
|
||||
.navigationBarsPadding()
|
||||
.imePadding(),
|
||||
onMessageSent = { content ->
|
||||
viewModel.sendMessage(content)
|
||||
},
|
||||
sendMessageEnabled = sendBtnEnable,
|
||||
resetScroll = {
|
||||
scope.launch {
|
||||
scrollState.scrollToItem(0)
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
fun open(activity: AppCompatActivity) {
|
||||
with(activity) {
|
||||
startActivity(Intent(this, AIActivity::class.java))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
package app.affine.pro.ai.chat
|
||||
|
||||
import com.affine.pro.graphql.fragment.CopilotChatHistory
|
||||
import kotlinx.datetime.Clock
|
||||
import kotlinx.datetime.Instant
|
||||
|
||||
data class MessageUiState(val messages: List<ChatMessage>) {
|
||||
|
||||
fun addMessage(message: ChatMessage) = MessageUiState(messages = listOf(message) + messages)
|
||||
|
||||
fun updateMessageAt(index: Int, transformer: (ChatMessage) -> ChatMessage) = MessageUiState(
|
||||
messages = messages.toMutableList().apply {
|
||||
add(index, transformer(removeAt(index)))
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
data class ChatMessage(
|
||||
val id: String?,
|
||||
val role: Role,
|
||||
val content: String,
|
||||
val createAt: Instant,
|
||||
) {
|
||||
enum class Role(val value: String) {
|
||||
User("user"),
|
||||
AI("assistant");
|
||||
|
||||
companion object {
|
||||
fun fromValue(role: String): Role {
|
||||
return entries.first { it.value == role }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
|
||||
const val AI_LOCAL_ID = "ai_local_id"
|
||||
const val USER_LOCAL_ID = "user_local_id"
|
||||
|
||||
fun newUserMessage(id: String, content: String) = ChatMessage(
|
||||
id = id,
|
||||
role = Role.User,
|
||||
content = content,
|
||||
createAt = Clock.System.now(),
|
||||
)
|
||||
|
||||
fun newAIMessage() = ChatMessage(
|
||||
id = AI_LOCAL_ID,
|
||||
role = Role.AI,
|
||||
content = "",
|
||||
createAt = Clock.System.now(),
|
||||
)
|
||||
|
||||
fun from(message: CopilotChatHistory.Message) = ChatMessage(
|
||||
id = message.id,
|
||||
role = Role.fromValue(message.role),
|
||||
content = message.content,
|
||||
createAt = message.createdAt,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
package app.affine.pro.ai.chat
|
||||
|
||||
import android.widget.Toast
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import app.affine.pro.AFFiNEApp
|
||||
import app.affine.pro.service.GraphQLService
|
||||
import app.affine.pro.service.SSEService
|
||||
import app.affine.pro.service.WebService
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.catch
|
||||
import kotlinx.coroutines.flow.collect
|
||||
import kotlinx.coroutines.flow.flowOn
|
||||
import kotlinx.coroutines.flow.onEach
|
||||
import kotlinx.coroutines.flow.onStart
|
||||
import kotlinx.coroutines.flow.update
|
||||
import kotlinx.coroutines.launch
|
||||
import timber.log.Timber
|
||||
import javax.inject.Inject
|
||||
|
||||
@HiltViewModel
|
||||
class ChatViewModel @Inject constructor(
|
||||
private val webService: WebService,
|
||||
private val graphQLService: GraphQLService,
|
||||
private val sseService: SSEService,
|
||||
) : ViewModel() {
|
||||
|
||||
private lateinit var sessionId: String
|
||||
|
||||
private val _messagesUiState: MutableStateFlow<MessageUiState> =
|
||||
MutableStateFlow(MessageUiState(emptyList()))
|
||||
|
||||
private val _sendBtnUiState: MutableStateFlow<Boolean> = MutableStateFlow(false)
|
||||
|
||||
val messagesUiState: StateFlow<MessageUiState> = _messagesUiState
|
||||
|
||||
val sendBtnUiState: StateFlow<Boolean> = _sendBtnUiState
|
||||
|
||||
init {
|
||||
viewModelScope.launch {
|
||||
sessionId = graphQLService.getCopilotSession(
|
||||
webService.workspaceId(),
|
||||
webService.docId()
|
||||
).getOrNull() ?: graphQLService.createCopilotSession(
|
||||
workspaceId = webService.workspaceId(),
|
||||
docId = webService.docId(),
|
||||
).getOrElse {
|
||||
Timber.w(it, "Create session failed")
|
||||
return@launch
|
||||
}
|
||||
_sendBtnUiState.value = true
|
||||
Timber.i("Create session success:[ sessionId = $sessionId].")
|
||||
val historyMessages = graphQLService.getCopilotHistories(
|
||||
workspaceId = webService.workspaceId(),
|
||||
docId = webService.docId(),
|
||||
sessionId = sessionId,
|
||||
).getOrDefault(emptyList()).map {
|
||||
ChatMessage.from(it)
|
||||
}.sortedByDescending {
|
||||
it.createAt
|
||||
}
|
||||
_messagesUiState.value = MessageUiState(historyMessages)
|
||||
}
|
||||
}
|
||||
|
||||
fun sendMessage(message: String) {
|
||||
viewModelScope.launch {
|
||||
_sendBtnUiState.value = false
|
||||
val messageId = graphQLService.createCopilotMessage(
|
||||
sessionId = sessionId,
|
||||
message = message,
|
||||
).getOrElse {
|
||||
Timber.e(it, "Send message fail.")
|
||||
Toast.makeText(AFFiNEApp.context(), "Loading.", Toast.LENGTH_SHORT).show()
|
||||
return@launch
|
||||
}
|
||||
Timber.i("Send message: $messageId")
|
||||
_messagesUiState.update {
|
||||
it.addMessage(ChatMessage.newUserMessage(messageId, message))
|
||||
}
|
||||
sseService.messageStream(sessionId, messageId)
|
||||
.onStart {
|
||||
_messagesUiState.update {
|
||||
it.addMessage(ChatMessage.newAIMessage())
|
||||
}
|
||||
}
|
||||
.onEach {
|
||||
val event = it.getOrThrow()
|
||||
Timber.d("On sse event: $event")
|
||||
when (event.type) {
|
||||
"message" -> _messagesUiState.update { state ->
|
||||
state.updateMessageAt(0) { message ->
|
||||
message.copy(content = message.content + event.data)
|
||||
}
|
||||
}
|
||||
"error" -> Timber.e(event.data)
|
||||
}
|
||||
}
|
||||
.catch {
|
||||
Timber.e(it, "Receive message fail.")
|
||||
}
|
||||
.flowOn(Dispatchers.IO)
|
||||
.collect()
|
||||
val ids = graphQLService.getCopilotHistoryIds(
|
||||
webService.workspaceId(),
|
||||
webService.docId(),
|
||||
sessionId,
|
||||
).getOrElse { emptyList() }
|
||||
if (ids.isNotEmpty() && ids.size == _messagesUiState.value.messages.size) {
|
||||
_messagesUiState.update { state ->
|
||||
state.updateMessageAt(0) { message ->
|
||||
message.copy(id = ids.last().id)
|
||||
}
|
||||
}
|
||||
}
|
||||
_sendBtnUiState.value = true
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
package app.affine.pro.ai.chat.ui
|
||||
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.material3.DropdownMenuItem
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TopAppBarScrollBehavior
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import app.affine.pro.R
|
||||
import app.affine.pro.components.AFFiNEAppBar
|
||||
import app.affine.pro.components.AFFiNEDropMenu
|
||||
import app.affine.pro.components.AFFiNEIcon
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun ChatAppBar(
|
||||
modifier: Modifier = Modifier,
|
||||
scrollBehavior: TopAppBarScrollBehavior,
|
||||
onBackClick: () -> Unit = { },
|
||||
onClearHistory: () -> Unit = { },
|
||||
onSaveAsChatBlock: () -> Unit = { },
|
||||
) {
|
||||
AFFiNEAppBar(
|
||||
modifier = modifier,
|
||||
scrollBehavior = scrollBehavior,
|
||||
onNavIconPressed = onBackClick,
|
||||
title = {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Text("Chat with AI", fontSize = 17.sp, fontWeight = FontWeight.Bold)
|
||||
}
|
||||
},
|
||||
actions = {
|
||||
AFFiNEDropMenu(
|
||||
R.drawable.ic_more_horizontal,
|
||||
modifier = Modifier.size(44.dp),
|
||||
menuItems = {
|
||||
DropdownMenuItem(
|
||||
text = { Text("Clear history") },
|
||||
trailingIcon = {
|
||||
AFFiNEIcon(R.drawable.ic_broom)
|
||||
},
|
||||
onClick = onClearHistory,
|
||||
)
|
||||
DropdownMenuItem(
|
||||
text = { Text("Save as chat block") },
|
||||
trailingIcon = {
|
||||
AFFiNEIcon(R.drawable.ic_bubble)
|
||||
},
|
||||
onClick = onSaveAsChatBlock,
|
||||
)
|
||||
}
|
||||
)
|
||||
}
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
package app.affine.pro.ai.chat.ui
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import app.affine.pro.R
|
||||
import app.affine.pro.ai.chat.ChatMessage
|
||||
import app.affine.pro.components.AFFiNEIcon
|
||||
import app.affine.pro.components.Markdown
|
||||
import app.affine.pro.theme.AFFiNETheme
|
||||
import kotlinx.datetime.Clock
|
||||
|
||||
@Composable
|
||||
fun Message(message: ChatMessage) {
|
||||
Column(
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.let {
|
||||
if (message.role == ChatMessage.Role.User) {
|
||||
it.background(
|
||||
color = Color.White.copy(alpha = 0.1f),
|
||||
shape = RoundedCornerShape(10.dp),
|
||||
)
|
||||
} else {
|
||||
it
|
||||
}
|
||||
}
|
||||
.padding(8.dp)
|
||||
) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
AFFiNEIcon(
|
||||
R.drawable.ic_ai,
|
||||
tint = AFFiNETheme.colors.iconActivated
|
||||
)
|
||||
Spacer(Modifier.width(6.dp))
|
||||
Text(
|
||||
text = when (message.role) {
|
||||
ChatMessage.Role.User -> "You"
|
||||
ChatMessage.Role.AI -> "Affine AI"
|
||||
},
|
||||
color = AFFiNETheme.colors.textPrimary,
|
||||
fontSize = 17.sp,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
)
|
||||
}
|
||||
Spacer(Modifier.height(8.dp))
|
||||
when (message.role) {
|
||||
ChatMessage.Role.User -> Text(
|
||||
text = message.content,
|
||||
color = Color.White,
|
||||
fontSize = 16.sp,
|
||||
)
|
||||
|
||||
ChatMessage.Role.AI -> Markdown(markdown = message.content)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Preview
|
||||
@Composable
|
||||
fun UserMessagePreview() {
|
||||
Column {
|
||||
Message(
|
||||
ChatMessage(
|
||||
id = null,
|
||||
role = ChatMessage.Role.User,
|
||||
content = "Feel free to input any text and see how AI ABC responds. Give it a go!",
|
||||
createAt = Clock.System.now(),
|
||||
)
|
||||
)
|
||||
|
||||
Spacer(Modifier.height(16.dp))
|
||||
|
||||
Message(
|
||||
ChatMessage(
|
||||
id = null,
|
||||
role = ChatMessage.Role.AI,
|
||||
content = "Go ahead and type in any message to see how our AI system will reply. Try it out!",
|
||||
createAt = Clock.System.now(),
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,248 @@
|
||||
package app.affine.pro.ai.chat.ui
|
||||
|
||||
import androidx.activity.compose.BackHandler
|
||||
import androidx.compose.foundation.ExperimentalFoundationApi
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.BoxScope
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.layout.wrapContentHeight
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.text.BasicTextField
|
||||
import androidx.compose.foundation.text.KeyboardActions
|
||||
import androidx.compose.foundation.text.KeyboardOptions
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.saveable.rememberSaveable
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.focus.onFocusChanged
|
||||
import androidx.compose.ui.graphics.SolidColor
|
||||
import androidx.compose.ui.platform.LocalFocusManager
|
||||
import androidx.compose.ui.platform.LocalSoftwareKeyboardController
|
||||
import androidx.compose.ui.semantics.SemanticsPropertyKey
|
||||
import androidx.compose.ui.semantics.SemanticsPropertyReceiver
|
||||
import androidx.compose.ui.semantics.contentDescription
|
||||
import androidx.compose.ui.semantics.semantics
|
||||
import androidx.compose.ui.text.input.ImeAction
|
||||
import androidx.compose.ui.text.input.KeyboardType
|
||||
import androidx.compose.ui.text.input.TextFieldValue
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.dp
|
||||
import app.affine.pro.R
|
||||
import app.affine.pro.components.AFFiNEIconButton
|
||||
import app.affine.pro.theme.AFFiNETheme
|
||||
import app.affine.pro.theme.ThemeMode
|
||||
|
||||
enum class InputSelector {
|
||||
NONE,
|
||||
CAMERA,
|
||||
PICTURE
|
||||
}
|
||||
|
||||
@Preview
|
||||
@Composable
|
||||
fun UserInputPreview() {
|
||||
AFFiNETheme(ThemeMode.Dark) {
|
||||
UserInput(onMessageSent = {})
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalFoundationApi::class)
|
||||
@Composable
|
||||
fun UserInput(
|
||||
modifier: Modifier = Modifier,
|
||||
onMessageSent: (String) -> Unit,
|
||||
sendMessageEnabled: Boolean = true,
|
||||
resetScroll: () -> Unit = {},
|
||||
) {
|
||||
var currentInputSelector by rememberSaveable { mutableStateOf(InputSelector.NONE) }
|
||||
val keyboardController = LocalSoftwareKeyboardController.current
|
||||
val focusManager = LocalFocusManager.current
|
||||
val dismissKeyboard = {
|
||||
currentInputSelector = InputSelector.NONE
|
||||
keyboardController?.hide()
|
||||
focusManager.clearFocus()
|
||||
}
|
||||
|
||||
if (currentInputSelector != InputSelector.NONE) {
|
||||
BackHandler(onBack = { dismissKeyboard() })
|
||||
}
|
||||
|
||||
var textState by rememberSaveable(stateSaver = TextFieldValue.Saver) {
|
||||
mutableStateOf(TextFieldValue())
|
||||
}
|
||||
|
||||
var textFieldFocusState by remember { mutableStateOf(false) }
|
||||
|
||||
Surface(
|
||||
tonalElevation = 2.dp,
|
||||
shadowElevation = 2.dp,
|
||||
shape = RoundedCornerShape(16.dp, 16.dp, 0.dp, 0.dp),
|
||||
color = AFFiNETheme.colors.backgroundOverlayPanel,
|
||||
) {
|
||||
Column(modifier = modifier) {
|
||||
UserInputText(
|
||||
textFieldValue = textState,
|
||||
onTextChanged = { textState = it },
|
||||
keyboardShown = currentInputSelector == InputSelector.NONE && textFieldFocusState,
|
||||
onTextFieldFocused = { focused ->
|
||||
if (focused) {
|
||||
currentInputSelector = InputSelector.NONE
|
||||
resetScroll()
|
||||
}
|
||||
textFieldFocusState = focused
|
||||
},
|
||||
onMessageSent = {
|
||||
onMessageSent(textState.text)
|
||||
textState = TextFieldValue()
|
||||
},
|
||||
focusState = textFieldFocusState
|
||||
)
|
||||
UserInputSelector(
|
||||
onSelectorChange = { currentInputSelector = it },
|
||||
sendMessageEnabled = textState.text.isNotBlank() && sendMessageEnabled,
|
||||
onMessageSent = {
|
||||
onMessageSent(textState.text)
|
||||
textState = TextFieldValue()
|
||||
dismissKeyboard()
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun UserInputSelector(
|
||||
onSelectorChange: (InputSelector) -> Unit,
|
||||
sendMessageEnabled: Boolean,
|
||||
onMessageSent: () -> Unit,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
Row(
|
||||
modifier = modifier
|
||||
.wrapContentHeight()
|
||||
.padding(10.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
AFFiNEIconButton(
|
||||
R.drawable.ic_camera,
|
||||
onClick = { onSelectorChange(InputSelector.CAMERA) },
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.width(14.dp))
|
||||
|
||||
AFFiNEIconButton(
|
||||
R.drawable.ic_image,
|
||||
onClick = { onSelectorChange(InputSelector.PICTURE) },
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.weight(1f))
|
||||
|
||||
// Send button
|
||||
AFFiNEIconButton(
|
||||
R.drawable.ic_send,
|
||||
enabled = sendMessageEnabled,
|
||||
onClick = onMessageSent,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
val KeyboardShownKey = SemanticsPropertyKey<Boolean>("KeyboardShownKey")
|
||||
var SemanticsPropertyReceiver.keyboardShownProperty by KeyboardShownKey
|
||||
|
||||
@ExperimentalFoundationApi
|
||||
@Composable
|
||||
private fun UserInputText(
|
||||
keyboardType: KeyboardType = KeyboardType.Text,
|
||||
onTextChanged: (TextFieldValue) -> Unit,
|
||||
textFieldValue: TextFieldValue,
|
||||
keyboardShown: Boolean,
|
||||
onTextFieldFocused: (Boolean) -> Unit,
|
||||
onMessageSent: (String) -> Unit,
|
||||
focusState: Boolean
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(66.dp),
|
||||
horizontalArrangement = Arrangement.End
|
||||
) {
|
||||
|
||||
Box(Modifier.fillMaxSize()) {
|
||||
UserInputTextField(
|
||||
textFieldValue,
|
||||
onTextChanged,
|
||||
onTextFieldFocused,
|
||||
keyboardType,
|
||||
focusState,
|
||||
onMessageSent,
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.semantics {
|
||||
contentDescription = "Text Input"
|
||||
keyboardShownProperty = keyboardShown
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun BoxScope.UserInputTextField(
|
||||
textFieldValue: TextFieldValue,
|
||||
onTextChanged: (TextFieldValue) -> Unit,
|
||||
onTextFieldFocused: (Boolean) -> Unit,
|
||||
keyboardType: KeyboardType,
|
||||
focusState: Boolean,
|
||||
onMessageSent: (String) -> Unit,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
var lastFocusState by remember { mutableStateOf(false) }
|
||||
val color = AFFiNETheme.colors.textPrimary
|
||||
BasicTextField(
|
||||
value = textFieldValue,
|
||||
onValueChange = { onTextChanged(it) },
|
||||
modifier = modifier
|
||||
.padding(start = 10.dp, end = 10.dp, top = 10.dp)
|
||||
.align(Alignment.CenterStart)
|
||||
.onFocusChanged { state ->
|
||||
if (lastFocusState != state.isFocused) {
|
||||
onTextFieldFocused(state.isFocused)
|
||||
}
|
||||
lastFocusState = state.isFocused
|
||||
},
|
||||
keyboardOptions = KeyboardOptions(
|
||||
keyboardType = keyboardType,
|
||||
imeAction = ImeAction.Send
|
||||
),
|
||||
keyboardActions = KeyboardActions {
|
||||
if (textFieldValue.text.isNotBlank()) onMessageSent(textFieldValue.text)
|
||||
},
|
||||
cursorBrush = SolidColor(color),
|
||||
textStyle = MaterialTheme.typography.bodyMedium.copy(color = color),
|
||||
)
|
||||
|
||||
if (textFieldValue.text.isEmpty() && !focusState) {
|
||||
Text(
|
||||
modifier = Modifier
|
||||
.align(Alignment.CenterStart)
|
||||
.padding(start = 10.dp, end = 10.dp, top = 10.dp),
|
||||
text = "Feel free to input any text and see how AI responds. Give it a go!",
|
||||
color = color
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
package app.affine.pro.components
|
||||
|
||||
import androidx.annotation.DrawableRes
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.ColumnScope
|
||||
import androidx.compose.foundation.layout.RowScope
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.material3.CenterAlignedTopAppBar
|
||||
import androidx.compose.material3.DropdownMenu
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TopAppBarDefaults
|
||||
import androidx.compose.material3.TopAppBarScrollBehavior
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.dp
|
||||
import app.affine.pro.R
|
||||
import app.affine.pro.theme.AFFiNETheme
|
||||
import app.affine.pro.theme.ThemeMode
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun AFFiNEAppBar(
|
||||
modifier: Modifier = Modifier,
|
||||
scrollBehavior: TopAppBarScrollBehavior? = null,
|
||||
onNavIconPressed: () -> Unit = { },
|
||||
title: @Composable () -> Unit,
|
||||
actions: @Composable RowScope.() -> Unit = {}
|
||||
) {
|
||||
CenterAlignedTopAppBar(
|
||||
modifier = modifier,
|
||||
actions = actions,
|
||||
title = title,
|
||||
scrollBehavior = scrollBehavior,
|
||||
colors = TopAppBarDefaults.centerAlignedTopAppBarColors().copy(
|
||||
containerColor = AFFiNETheme.colors.backgroundPrimary,
|
||||
titleContentColor = AFFiNETheme.colors.textPrimary,
|
||||
),
|
||||
navigationIcon = {
|
||||
AFFiNEIconButton(
|
||||
R.drawable.ic_close,
|
||||
modifier = Modifier.size(44.dp),
|
||||
onClick = onNavIconPressed
|
||||
)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun AFFiNEDropMenu(
|
||||
@DrawableRes resId: Int,
|
||||
modifier: Modifier,
|
||||
menuItems: @Composable ColumnScope.() -> Unit = {}
|
||||
) {
|
||||
var expanded by remember { mutableStateOf(false) }
|
||||
Box {
|
||||
AFFiNEIconButton(
|
||||
resId,
|
||||
modifier = modifier,
|
||||
onClick = { expanded = !expanded },
|
||||
)
|
||||
DropdownMenu(
|
||||
expanded = expanded,
|
||||
onDismissRequest = { expanded = false }
|
||||
) {
|
||||
menuItems()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Preview
|
||||
@Composable
|
||||
fun AffineAppBarPreviewLight() {
|
||||
AFFiNETheme(mode = ThemeMode.Light) {
|
||||
AFFiNEAppBar(
|
||||
title = { Text("Preview!") },
|
||||
actions = {
|
||||
AFFiNEDropMenu(R.drawable.ic_more_horizontal, Modifier.size(44.dp))
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Preview
|
||||
@Composable
|
||||
fun AffineAppBarPreviewDark() {
|
||||
AFFiNETheme(mode = ThemeMode.Dark) {
|
||||
AFFiNEAppBar(
|
||||
title = { Text("Preview!") },
|
||||
actions = {
|
||||
AFFiNEDropMenu(R.drawable.ic_more_horizontal, Modifier.size(44.dp))
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
package app.affine.pro.components
|
||||
|
||||
import androidx.annotation.DrawableRes
|
||||
import androidx.compose.foundation.interaction.MutableInteractionSource
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.IconButtonColors
|
||||
import androidx.compose.material3.IconButtonDefaults
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
import app.affine.pro.theme.AFFiNETheme
|
||||
|
||||
@Composable
|
||||
fun AFFiNEIcon(
|
||||
@DrawableRes resId: Int,
|
||||
modifier: Modifier = Modifier,
|
||||
tint: Color = AFFiNETheme.colors.iconPrimary,
|
||||
contentDescription: String? = null,
|
||||
) {
|
||||
Icon(
|
||||
painter = painterResource(resId),
|
||||
tint = tint,
|
||||
contentDescription = contentDescription,
|
||||
modifier = modifier.size(24.dp)
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun AFFiNEIconButton(
|
||||
@DrawableRes resId: Int,
|
||||
onClick: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
enabled: Boolean = true,
|
||||
colors: IconButtonColors = IconButtonDefaults.iconButtonColors().copy(
|
||||
contentColor = AFFiNETheme.colors.iconPrimary,
|
||||
disabledContentColor = AFFiNETheme.colors.iconDisable,
|
||||
),
|
||||
interactionSource: MutableInteractionSource? = null,
|
||||
) {
|
||||
IconButton(onClick, modifier.size(24.dp), enabled, colors, interactionSource) {
|
||||
AFFiNEIcon(resId)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,223 @@
|
||||
package app.affine.pro.components
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.CompositionLocalProvider
|
||||
import androidx.compose.runtime.staticCompositionLocalOf
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.text.SpanStyle
|
||||
import androidx.compose.ui.text.TextLinkStyles
|
||||
import androidx.compose.ui.text.TextStyle
|
||||
import androidx.compose.ui.text.font.FontFamily
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.BaselineShift
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import app.affine.pro.R
|
||||
import app.affine.pro.theme.AFFiNETheme
|
||||
import app.affine.pro.theme.ThemeMode
|
||||
import com.halilibo.richtext.commonmark.Markdown
|
||||
import com.halilibo.richtext.ui.BasicRichText
|
||||
import com.halilibo.richtext.ui.BlockQuoteGutter
|
||||
import com.halilibo.richtext.ui.CodeBlockStyle
|
||||
import com.halilibo.richtext.ui.HeadingStyle
|
||||
import com.halilibo.richtext.ui.ListStyle
|
||||
import com.halilibo.richtext.ui.OrderedMarkers
|
||||
import com.halilibo.richtext.ui.RichTextStyle
|
||||
import com.halilibo.richtext.ui.RichTextThemeProvider
|
||||
import com.halilibo.richtext.ui.TableStyle
|
||||
import com.halilibo.richtext.ui.UnorderedMarkers
|
||||
import com.halilibo.richtext.ui.string.RichTextStringStyle
|
||||
|
||||
private val LocalMarkdownTextStyle = staticCompositionLocalOf {
|
||||
TextStyle(
|
||||
fontSize = 16.sp,
|
||||
lineHeight = 21.sp,
|
||||
fontWeight = FontWeight.Normal,
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun Markdown(
|
||||
modifier: Modifier = Modifier,
|
||||
markdown: String,
|
||||
) {
|
||||
RichTextThemeProvider(
|
||||
contentColorProvider = { AFFiNETheme.colors.textPrimary },
|
||||
textStyleProvider = { LocalMarkdownTextStyle.current },
|
||||
textStyleBackProvider = { textStyle, content ->
|
||||
CompositionLocalProvider(LocalMarkdownTextStyle provides textStyle, content)
|
||||
}
|
||||
) {
|
||||
val dividerColor = AFFiNETheme.colors.divider
|
||||
BasicRichText(
|
||||
modifier = modifier,
|
||||
style = RichTextStyle(
|
||||
headingStyle = headingStyle,
|
||||
listStyle = listStyle,
|
||||
blockQuoteGutter = BlockQuoteGutter.BarGutter(
|
||||
startMargin = 0.sp,
|
||||
barWidth = 5.sp,
|
||||
endMargin = 8.sp,
|
||||
color = { dividerColor },
|
||||
),
|
||||
codeBlockStyle = CodeBlockStyle(
|
||||
textStyle = AFFiNETheme.typography.body.copy(
|
||||
fontSize = 14.sp, fontFamily = FontFamily.Monospace
|
||||
),
|
||||
modifier = Modifier
|
||||
.fillMaxWidth(1f)
|
||||
.background(
|
||||
color = AFFiNETheme.colors.backgroundCodeBlock,
|
||||
shape = RoundedCornerShape(8.dp),
|
||||
),
|
||||
padding = 16.sp,
|
||||
wordWrap = true,
|
||||
),
|
||||
tableStyle = TableStyle(borderColor = dividerColor, borderStrokeWidth = 2.dp.value),
|
||||
stringStyle = RichTextStringStyle(
|
||||
linkStyle = TextLinkStyles(
|
||||
SpanStyle(color = AFFiNETheme.colors.textEmphasis),
|
||||
SpanStyle(color = AFFiNETheme.colors.textEmphasis),
|
||||
SpanStyle(color = AFFiNETheme.colors.textEmphasis),
|
||||
SpanStyle(color = AFFiNETheme.colors.textEmphasis),
|
||||
),
|
||||
codeStyle = SpanStyle(
|
||||
fontSize = 13.sp,
|
||||
baselineShift = BaselineShift(0.08f),
|
||||
background = AFFiNETheme.colors.backgroundCodeBlock,
|
||||
)
|
||||
)
|
||||
),
|
||||
) {
|
||||
Markdown(markdown)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private val headingStyle: HeadingStyle = { level, textStyle ->
|
||||
when (level) {
|
||||
0 -> TextStyle(
|
||||
fontSize = 28.sp, lineHeight = 34.sp, fontWeight = FontWeight.SemiBold
|
||||
)
|
||||
|
||||
1 -> TextStyle(
|
||||
fontSize = 22.sp, lineHeight = 28.sp, fontWeight = FontWeight.SemiBold
|
||||
)
|
||||
|
||||
2 -> TextStyle(
|
||||
fontSize = 20.sp, lineHeight = 25.sp, fontWeight = FontWeight.SemiBold
|
||||
)
|
||||
|
||||
3 -> TextStyle(
|
||||
fontSize = 17.sp, lineHeight = 22.sp, fontWeight = FontWeight.SemiBold
|
||||
)
|
||||
|
||||
4 -> TextStyle(
|
||||
fontSize = 17.sp, lineHeight = 22.sp, fontWeight = FontWeight.SemiBold
|
||||
)
|
||||
|
||||
else -> textStyle.copy(fontWeight = FontWeight.SemiBold)
|
||||
}
|
||||
}
|
||||
|
||||
private val listStyle = ListStyle(orderedMarkers = {
|
||||
OrderedMarkers { _, index ->
|
||||
Text(
|
||||
"${index + 1}.",
|
||||
modifier = Modifier
|
||||
.width(24.dp)
|
||||
.padding(start = 4.dp),
|
||||
style = AFFiNETheme.typography.body,
|
||||
color = AFFiNETheme.colors.textEmphasis,
|
||||
)
|
||||
}
|
||||
}, unorderedMarkers = {
|
||||
val markers = listOf(
|
||||
R.drawable.ic_bulleted_list_01,
|
||||
R.drawable.ic_bulleted_list_02,
|
||||
R.drawable.ic_bulleted_list_03,
|
||||
R.drawable.ic_bulleted_list_04,
|
||||
)
|
||||
UnorderedMarkers { level ->
|
||||
AFFiNEIcon(markers[level % markers.size], tint = AFFiNETheme.colors.textEmphasis)
|
||||
}
|
||||
})
|
||||
|
||||
@Preview
|
||||
@Composable
|
||||
fun MarkdownPreview() {
|
||||
AFFiNETheme(mode = ThemeMode.Dark) {
|
||||
Markdown(
|
||||
markdown = """
|
||||
|
||||
|
||||
当然可以,大熊!下面是一个包含**所有常用 Markdown 格式**的示例内容,您可以直接复制到 AFFiNE 或其他支持 Markdown 的编辑器中体验效果:
|
||||
|
||||
---
|
||||
|
||||
# 这是一级标题
|
||||
|
||||
## 这是二级标题
|
||||
|
||||
### 这是三级标题
|
||||
|
||||
---
|
||||
|
||||
**加粗文本***斜体文本*~~删除线文本~~`行内代码`
|
||||
|
||||
---
|
||||
|
||||
> 这是一个引用块,可以用来高亮重要信息。
|
||||
|
||||
---
|
||||
|
||||
- 无序列表项 1
|
||||
- 无序列表项 2
|
||||
- 嵌套子项
|
||||
- 嵌套子项
|
||||
- 嵌套子项
|
||||
|
||||
1. 有序列表项 1
|
||||
1. 有序列表项 1-1
|
||||
2. 有序列表项 1-2
|
||||
2. 有序列表项 2
|
||||
|
||||
---
|
||||
|
||||
| 姓名 | 年龄 | 爱好 |
|
||||
| ------ | ---- | -------- |
|
||||
| 大熊 | 28 | 阅读 |
|
||||
| 阿芬 | ∞ | 帮助你 |
|
||||
|
||||
---
|
||||
|
||||
```kotlin
|
||||
// 这是一个 Kotlin 代码块
|
||||
fun main() {
|
||||
println(\"Hello, Markdown!\")
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||

|
||||
|
||||
---
|
||||
|
||||
[这是一个链接,点我访问 AFFiNE 官网](https://affine.pro)
|
||||
|
||||
---
|
||||
|
||||
大熊,如果还想体验更多格式或者有特殊内容需求,随时告诉阿芬!
|
||||
|
||||
""".trimIndent()
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
package app.affine.pro.plugin
|
||||
|
||||
import com.getcapacitor.JSObject
|
||||
import com.getcapacitor.Plugin
|
||||
import com.getcapacitor.PluginCall
|
||||
import com.getcapacitor.PluginMethod
|
||||
import com.getcapacitor.annotation.CapacitorPlugin
|
||||
import timber.log.Timber
|
||||
|
||||
@CapacitorPlugin(name = "AffineTheme")
|
||||
class AFFiNEThemePlugin : Plugin() {
|
||||
|
||||
interface Callback {
|
||||
fun onThemeChanged(darkMode: Boolean)
|
||||
fun getSystemNavBarHeight(): Int
|
||||
}
|
||||
|
||||
@PluginMethod
|
||||
fun onThemeChanged(call: PluginCall) {
|
||||
val darkMode = call.data.optBoolean("darkMode")
|
||||
Timber.i("onThemeChanged:[ darkMode = $darkMode ]")
|
||||
(bridge.activity as? Callback)?.onThemeChanged(darkMode)
|
||||
call.resolve()
|
||||
}
|
||||
|
||||
@PluginMethod
|
||||
fun getSystemNavBarHeight(call: PluginCall) {
|
||||
val height = (bridge.activity as? Callback)?.getSystemNavBarHeight() ?: 0
|
||||
call.resolve(JSObject().put("height", height))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
package app.affine.pro.plugin
|
||||
|
||||
import com.getcapacitor.Plugin
|
||||
import com.getcapacitor.PluginCall
|
||||
import com.getcapacitor.PluginMethod
|
||||
import com.getcapacitor.annotation.CapacitorPlugin
|
||||
import timber.log.Timber
|
||||
|
||||
@CapacitorPlugin(name = "AIButton")
|
||||
class AIButtonPlugin : Plugin() {
|
||||
|
||||
interface Callback {
|
||||
fun present()
|
||||
fun dismiss()
|
||||
}
|
||||
|
||||
@PluginMethod
|
||||
fun present(call: PluginCall) {
|
||||
launch {
|
||||
Timber.i("present AIButton")
|
||||
(activity as? Callback)?.present()
|
||||
call.resolve()
|
||||
}
|
||||
}
|
||||
|
||||
@PluginMethod
|
||||
fun dismiss(call: PluginCall) {
|
||||
launch {
|
||||
Timber.i("dismiss AIButton")
|
||||
(activity as? Callback)?.dismiss()
|
||||
call.resolve()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,492 @@
|
||||
package app.affine.pro.plugin
|
||||
|
||||
import android.security.keystore.KeyGenParameterSpec
|
||||
import android.security.keystore.KeyProperties
|
||||
import android.util.Base64
|
||||
import app.affine.pro.AFFiNEApp
|
||||
import app.affine.pro.service.AuthHttp
|
||||
import app.affine.pro.service.CookieStore
|
||||
import app.affine.pro.utils.authDataStore
|
||||
import app.affine.pro.utils.del
|
||||
import app.affine.pro.utils.get
|
||||
import app.affine.pro.utils.set
|
||||
import com.getcapacitor.JSObject
|
||||
import com.getcapacitor.Plugin
|
||||
import com.getcapacitor.PluginCall
|
||||
import com.getcapacitor.PluginMethod
|
||||
import com.getcapacitor.annotation.CapacitorPlugin
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Deferred
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.async
|
||||
import kotlinx.coroutines.currentCoroutineContext
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.ensureActive
|
||||
import kotlinx.coroutines.sync.Mutex
|
||||
import kotlinx.coroutines.sync.withLock
|
||||
import okhttp3.HttpUrl
|
||||
import okhttp3.HttpUrl.Companion.toHttpUrl
|
||||
import okhttp3.MediaType.Companion.toMediaType
|
||||
import okhttp3.Request
|
||||
import okhttp3.RequestBody.Companion.toRequestBody
|
||||
import okhttp3.coroutines.executeAsync
|
||||
import org.json.JSONObject
|
||||
import timber.log.Timber
|
||||
import java.security.KeyStore
|
||||
import java.security.MessageDigest
|
||||
import java.text.ParsePosition
|
||||
import java.text.SimpleDateFormat
|
||||
import java.util.Locale
|
||||
import java.util.TimeZone
|
||||
import java.util.UUID
|
||||
import java.util.concurrent.ConcurrentHashMap
|
||||
import javax.crypto.Cipher
|
||||
import javax.crypto.KeyGenerator
|
||||
import javax.crypto.SecretKey
|
||||
import javax.crypto.spec.GCMParameterSpec
|
||||
import kotlin.math.pow
|
||||
import kotlin.random.Random
|
||||
|
||||
private data class TokenPair(
|
||||
val accessToken: String,
|
||||
val accessExpiresAt: Long,
|
||||
val refreshToken: String,
|
||||
val json: String,
|
||||
)
|
||||
|
||||
private class AuthServerException(
|
||||
val code: String?,
|
||||
val status: Int,
|
||||
override val message: String,
|
||||
) : Exception(message)
|
||||
|
||||
private fun authServerException(status: Int, text: String): AuthServerException {
|
||||
val body = runCatching { JSONObject(text) }.getOrNull()
|
||||
val code = body?.optString("code")?.takeIf { it.isNotEmpty() }
|
||||
?: body?.optString("name")?.takeIf { it.isNotEmpty() }
|
||||
val message = body?.optString("message")?.takeIf { it.isNotEmpty() }
|
||||
?: "Authentication request failed with status $status"
|
||||
return AuthServerException(code, status, message)
|
||||
}
|
||||
|
||||
private val permanentAuthErrors = setOf(
|
||||
"ACCESS_TOKEN_INVALID", "AUTH_SESSION_EXPIRED", "AUTH_SESSION_REVOKED",
|
||||
"REFRESH_TOKEN_INVALID", "REFRESH_TOKEN_REUSED", "UNSUPPORTED_CLIENT_VERSION",
|
||||
)
|
||||
|
||||
private val publicAuthErrors = permanentAuthErrors + setOf(
|
||||
"ACCESS_TOKEN_EXPIRED", "AUTH_SESSION_TEMPORARILY_UNAVAILABLE", "TOO_MANY_REQUESTS",
|
||||
)
|
||||
private val publicInternalAuthErrors = setOf("AUTH_SESSION_EMPTY")
|
||||
|
||||
internal class AuthSessionBroker(
|
||||
private val storage: AuthCredentialStorage = AndroidAuthCredentialStorage(),
|
||||
) {
|
||||
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
|
||||
private val locks = ConcurrentHashMap<String, Mutex>()
|
||||
private val refreshes = ConcurrentHashMap<String, Deferred<TokenPair>>()
|
||||
private val epochs = ConcurrentHashMap<String, Long>()
|
||||
private val pending = ConcurrentHashMap<String, TokenPair>()
|
||||
|
||||
suspend fun store(endpoint: String, response: String) = lock(endpoint).withLock {
|
||||
invalidate(endpoint)
|
||||
write(endpoint, parsePair(response))
|
||||
}
|
||||
|
||||
suspend fun validAccessToken(endpoint: String): String? {
|
||||
repeat(2) {
|
||||
val current = lock(endpoint).withLock { read(endpoint) } ?: return null
|
||||
if (current.accessExpiresAt - System.currentTimeMillis() > 120_000) {
|
||||
return current.accessToken
|
||||
}
|
||||
try {
|
||||
return refresh(endpoint).accessToken
|
||||
} catch (error: Exception) {
|
||||
if (error is CancellationException) currentCoroutineContext().ensureActive()
|
||||
val mutationWon = error is CancellationException ||
|
||||
error is IllegalStateException && error.message == "AUTH_SESSION_EMPTY"
|
||||
if (!mutationWon) throw error
|
||||
}
|
||||
}
|
||||
return lock(endpoint).withLock { read(endpoint) }?.accessToken
|
||||
}
|
||||
|
||||
suspend fun refreshAccessToken(endpoint: String) = refresh(endpoint).accessToken
|
||||
|
||||
suspend fun clear(endpoint: String) = lock(endpoint).withLock {
|
||||
invalidate(endpoint)
|
||||
storage.delete(endpoint)
|
||||
}
|
||||
|
||||
suspend fun signOut(endpoint: String) {
|
||||
val pair = lock(endpoint).withLock {
|
||||
val current = read(endpoint)
|
||||
invalidate(endpoint)
|
||||
storage.delete(endpoint)
|
||||
current
|
||||
} ?: return
|
||||
request(endpoint, "/api/auth/session/revoke", JSONObject().put("refreshToken", pair.refreshToken))
|
||||
}
|
||||
|
||||
private suspend fun refresh(endpoint: String): TokenPair {
|
||||
val canonical = canonical(endpoint)
|
||||
refreshes[canonical]?.let { return it.await() }
|
||||
return lock(endpoint).withLock {
|
||||
refreshes[canonical]?.let { return@withLock it }
|
||||
val epoch = epochs[canonical] ?: 0
|
||||
val pendingPair = pending[canonical]
|
||||
val current = if (pendingPair == null) {
|
||||
read(endpoint) ?: throw IllegalStateException("AUTH_SESSION_EMPTY")
|
||||
} else {
|
||||
null
|
||||
}
|
||||
scope.async {
|
||||
try {
|
||||
if (pendingPair != null) {
|
||||
lock(endpoint).withLock {
|
||||
if ((epochs[canonical] ?: 0) != epoch) throw CancellationException()
|
||||
write(endpoint, pendingPair)
|
||||
if ((epochs[canonical] ?: 0) != epoch) throw CancellationException()
|
||||
pending.remove(canonical, pendingPair)
|
||||
}
|
||||
return@async pendingPair
|
||||
}
|
||||
val response = request(
|
||||
endpoint,
|
||||
"/api/auth/session/refresh",
|
||||
JSONObject().put("refreshToken", current!!.refreshToken),
|
||||
)
|
||||
val pair = parsePair(response)
|
||||
lock(endpoint).withLock {
|
||||
if ((epochs[canonical] ?: 0) != epoch) throw CancellationException()
|
||||
pending[canonical] = pair
|
||||
write(endpoint, pair)
|
||||
if ((epochs[canonical] ?: 0) != epoch) throw CancellationException()
|
||||
pending.remove(canonical, pair)
|
||||
}
|
||||
pair
|
||||
} catch (error: AuthServerException) {
|
||||
if (error.code in permanentAuthErrors) {
|
||||
lock(endpoint).withLock {
|
||||
if ((epochs[canonical] ?: 0) == epoch) {
|
||||
storage.delete(endpoint)
|
||||
}
|
||||
}
|
||||
}
|
||||
throw error
|
||||
}
|
||||
}.also { task ->
|
||||
refreshes[canonical] = task
|
||||
task.invokeOnCompletion { refreshes.remove(canonical, task) }
|
||||
}
|
||||
}.await()
|
||||
}
|
||||
|
||||
private suspend fun request(endpoint: String, path: String, body: JSONObject): String {
|
||||
var last: Exception? = null
|
||||
repeat(3) { attempt ->
|
||||
try {
|
||||
val request = Request.Builder()
|
||||
.url("${canonical(endpoint)}$path")
|
||||
.addHeader("x-affine-client-kind", "native")
|
||||
.post(body.toString().toRequestBody(jsonMediaType))
|
||||
.build()
|
||||
AuthHttp.client.newCall(request).executeAsync().use { response ->
|
||||
val text = response.body.string()
|
||||
if (response.code < 400) return text
|
||||
val error = authServerException(response.code, text)
|
||||
if (response.code < 500 || attempt == 2) throw error
|
||||
last = error
|
||||
}
|
||||
} catch (error: AuthServerException) {
|
||||
if (error.status < 500 || attempt == 2) throw error
|
||||
last = error
|
||||
} catch (error: Exception) {
|
||||
if (attempt == 2) throw error
|
||||
last = error
|
||||
}
|
||||
delay((200.0 * 2.0.pow(attempt) + Random.nextInt(151)).toLong())
|
||||
}
|
||||
throw last ?: IllegalStateException("AUTH_SESSION_TEMPORARILY_UNAVAILABLE")
|
||||
}
|
||||
|
||||
private fun parsePair(text: String): TokenPair {
|
||||
val value = JSONObject(text)
|
||||
val tokenType = value.optString("tokenType")
|
||||
val accessToken = value.optString("accessToken")
|
||||
val refreshToken = value.optString("refreshToken")
|
||||
val expiresIn = value.optLong("expiresIn")
|
||||
require(isIso8601(value.getString("refreshExpiresAt")))
|
||||
require(isIso8601(value.getJSONObject("session").getString("absoluteExpiresAt")))
|
||||
require(tokenType == "Bearer" && accessToken.isNotEmpty() && refreshToken.isNotEmpty())
|
||||
require(expiresIn in 1..86_400)
|
||||
val storedValue = JSONObject(value.toString())
|
||||
.put("version", 1)
|
||||
.put("accessExpiresAt", System.currentTimeMillis() + expiresIn * 1000)
|
||||
storedValue.remove("expiresIn")
|
||||
val stored = storedValue.toString()
|
||||
return TokenPair(accessToken, System.currentTimeMillis() + expiresIn * 1000, refreshToken, stored)
|
||||
}
|
||||
|
||||
private suspend fun read(endpoint: String): TokenPair? {
|
||||
val plaintext = storage.read(endpoint) ?: return null
|
||||
return try {
|
||||
val value = JSONObject(plaintext)
|
||||
require(value.getInt("version") == 1 && value.getString("tokenType") == "Bearer")
|
||||
require(isIso8601(value.getString("refreshExpiresAt")))
|
||||
require(isIso8601(value.getJSONObject("session").getString("absoluteExpiresAt")))
|
||||
TokenPair(
|
||||
value.getString("accessToken").also { require(it.isNotEmpty()) },
|
||||
value.getLong("accessExpiresAt"),
|
||||
value.getString("refreshToken").also { require(it.isNotEmpty()) },
|
||||
plaintext,
|
||||
)
|
||||
} catch (_: Exception) {
|
||||
loseCredential(endpoint)
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun loseCredential(endpoint: String): TokenPair? {
|
||||
storage.delete(endpoint)
|
||||
return null
|
||||
}
|
||||
|
||||
private suspend fun write(endpoint: String, pair: TokenPair) {
|
||||
storage.write(endpoint, pair.json)
|
||||
}
|
||||
|
||||
private fun invalidate(endpoint: String) {
|
||||
val canonical = canonical(endpoint)
|
||||
epochs.compute(canonical) { _, value -> (value ?: 0) + 1 }
|
||||
refreshes.remove(canonical)?.cancel()
|
||||
pending.remove(canonical)
|
||||
}
|
||||
|
||||
private fun lock(endpoint: String) = locks.computeIfAbsent(canonical(endpoint)) { Mutex() }
|
||||
companion object {
|
||||
private val jsonMediaType = "application/json".toMediaType()
|
||||
fun canonical(endpoint: String): String = try {
|
||||
val url = endpoint.toHttpUrl()
|
||||
val port = if (url.port == HttpUrl.defaultPort(url.scheme)) "" else ":${url.port}"
|
||||
"${url.scheme}://${url.host}$port"
|
||||
} catch (_: Exception) {
|
||||
endpoint
|
||||
}
|
||||
|
||||
private fun isIso8601(value: String): Boolean = listOf(
|
||||
"yyyy-MM-dd'T'HH:mm:ss.SSS'Z'",
|
||||
"yyyy-MM-dd'T'HH:mm:ss'Z'",
|
||||
).any { pattern ->
|
||||
val position = ParsePosition(0)
|
||||
val parsed = SimpleDateFormat(pattern, Locale.US).apply {
|
||||
isLenient = false
|
||||
timeZone = TimeZone.getTimeZone("UTC")
|
||||
}.parse(value, position)
|
||||
parsed != null && position.index == value.length
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@CapacitorPlugin(name = "Auth")
|
||||
class AuthPlugin : Plugin() {
|
||||
private val broker = AuthSessionBroker()
|
||||
|
||||
@PluginMethod fun getValidAccessToken(call: PluginCall) = bridge(call) {
|
||||
JSObject().put("token", broker.validAccessToken(call.getStringEnsure("endpoint")))
|
||||
}
|
||||
|
||||
@PluginMethod fun refreshAccessToken(call: PluginCall) = bridge(call) {
|
||||
JSObject().put("token", broker.refreshAccessToken(call.getStringEnsure("endpoint")))
|
||||
}
|
||||
|
||||
@PluginMethod fun clearEndpointSession(call: PluginCall) = bridge(call) {
|
||||
broker.clear(call.getStringEnsure("endpoint")); JSObject().put("ok", true)
|
||||
}
|
||||
|
||||
@PluginMethod fun signOut(call: PluginCall) = bridge(call) {
|
||||
val endpoint = call.getStringEnsure("endpoint")
|
||||
broker.signOut(endpoint)
|
||||
CookieStore.clearAuthCookies(endpoint.toHttpUrl().host)
|
||||
JSObject().put("ok", true)
|
||||
}
|
||||
|
||||
@PluginMethod fun signInMagicLink(call: PluginCall) = signIn(call, "magic")
|
||||
@PluginMethod fun signInOauth(call: PluginCall) = signIn(call, "oauth")
|
||||
@PluginMethod fun signInOpenApp(call: PluginCall) = signIn(call, "open-app")
|
||||
@PluginMethod fun signInPassword(call: PluginCall) = signIn(call, "password")
|
||||
|
||||
private fun signIn(call: PluginCall, method: String) = bridge(call) {
|
||||
val endpoint = call.getStringEnsure("endpoint")
|
||||
val (path, body) = when (method) {
|
||||
"password" -> "/api/auth/sign-in" to JSONObject()
|
||||
.put("email", call.getStringEnsure("email"))
|
||||
.put("password", call.getStringEnsure("password"))
|
||||
"oauth" -> "/api/oauth/callback" to JSONObject()
|
||||
.put("code", call.getStringEnsure("code"))
|
||||
.put("state", call.getStringEnsure("state"))
|
||||
.put("client_nonce", call.getString("clientNonce"))
|
||||
"magic" -> "/api/auth/magic-link" to JSONObject()
|
||||
.put("email", call.getStringEnsure("email"))
|
||||
.put("token", call.getStringEnsure("token"))
|
||||
.put("client_nonce", call.getString("clientNonce"))
|
||||
else -> "/api/auth/open-app/sign-in" to JSONObject().put("code", call.getStringEnsure("code"))
|
||||
}
|
||||
val request = Request.Builder()
|
||||
.url("${AuthSessionBroker.canonical(endpoint)}$path")
|
||||
.addHeader("x-affine-client-kind", "native")
|
||||
.post(body.toString().toRequestBody("application/json".toMediaType()))
|
||||
.apply {
|
||||
call.getString("verifyToken")?.let { addHeader("x-captcha-token", it) }
|
||||
call.getString("challenge")?.let { addHeader("x-captcha-challenge", it) }
|
||||
call.getString("verifyToken")?.let {
|
||||
addHeader(
|
||||
"x-captcha-provider",
|
||||
if (call.getString("challenge") == null) "turnstile" else "hashcash"
|
||||
)
|
||||
}
|
||||
}
|
||||
.build()
|
||||
val exchangeCode = AuthHttp.client.newCall(request).executeAsync().use { response ->
|
||||
val text = response.body.string()
|
||||
if (response.code >= 400) throw authServerException(response.code, text)
|
||||
JSONObject(text).getString("exchangeCode")
|
||||
}
|
||||
val exchangeBody = JSONObject()
|
||||
.put("code", exchangeCode)
|
||||
.put("installationId", installationId())
|
||||
.put("platform", "android")
|
||||
.put("deviceName", android.os.Build.MODEL)
|
||||
val exchangeRequest = Request.Builder()
|
||||
.url("${AuthSessionBroker.canonical(endpoint)}/api/auth/session/exchange")
|
||||
.addHeader("x-affine-client-kind", "native")
|
||||
.post(exchangeBody.toString().toRequestBody("application/json".toMediaType()))
|
||||
.build()
|
||||
val tokenResponse = AuthHttp.client.newCall(exchangeRequest).executeAsync().use { response ->
|
||||
val text = response.body.string()
|
||||
if (response.code >= 400) throw authServerException(response.code, text)
|
||||
text
|
||||
}
|
||||
broker.store(endpoint, tokenResponse)
|
||||
CookieStore.clearAuthCookies(endpoint.toHttpUrl().host)
|
||||
JSObject().put("ok", true)
|
||||
}
|
||||
|
||||
private suspend fun installationId(): String {
|
||||
val key = "auth-installation-id"
|
||||
val store = AFFiNEApp.context().authDataStore
|
||||
return store.get(key).takeIf { it.isNotEmpty() } ?: UUID.randomUUID().toString().also {
|
||||
store.set(key, it)
|
||||
}
|
||||
}
|
||||
|
||||
private fun bridge(call: PluginCall, block: suspend () -> JSObject) {
|
||||
launch(Dispatchers.IO) {
|
||||
try { call.resolve(block()) } catch (error: Exception) {
|
||||
Timber.w(error, "Auth operation failed")
|
||||
val code = when (error) {
|
||||
is AuthServerException -> when {
|
||||
error.code in publicAuthErrors -> error.code!!
|
||||
error.status == 429 -> "TOO_MANY_REQUESTS"
|
||||
else -> "AUTH_SESSION_TEMPORARILY_UNAVAILABLE"
|
||||
}
|
||||
is CancellationException -> "AUTH_OPERATION_CANCELLED"
|
||||
is IllegalStateException -> error.message
|
||||
?.takeIf { it in publicInternalAuthErrors }
|
||||
?: "AUTH_SESSION_TEMPORARILY_UNAVAILABLE"
|
||||
else -> "AUTH_SESSION_TEMPORARILY_UNAVAILABLE"
|
||||
}
|
||||
val message = if (error is AuthServerException) error.message else "Auth operation failed"
|
||||
call.reject(message, code, error)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
internal interface AuthCredentialStorage {
|
||||
suspend fun read(endpoint: String): String?
|
||||
suspend fun write(endpoint: String, value: String)
|
||||
suspend fun delete(endpoint: String)
|
||||
}
|
||||
|
||||
private class AndroidAuthCredentialStorage : AuthCredentialStorage {
|
||||
private val cipher = TokenCipher()
|
||||
|
||||
override suspend fun read(endpoint: String): String? {
|
||||
val canonical = AuthSessionBroker.canonical(endpoint)
|
||||
val encoded = AFFiNEApp.context().authDataStore.get(key(canonical)).takeIf { it.isNotEmpty() }
|
||||
?: return null
|
||||
return cipher.decrypt(canonical, encoded) ?: run {
|
||||
cipher.reset(canonical)
|
||||
delete(canonical)
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun write(endpoint: String, value: String) {
|
||||
val canonical = AuthSessionBroker.canonical(endpoint)
|
||||
AFFiNEApp.context().authDataStore.set(key(canonical), cipher.encrypt(canonical, value))
|
||||
}
|
||||
|
||||
override suspend fun delete(endpoint: String) {
|
||||
AFFiNEApp.context().authDataStore.del(key(AuthSessionBroker.canonical(endpoint)))
|
||||
}
|
||||
|
||||
private fun key(endpoint: String) = "auth-token:$endpoint"
|
||||
}
|
||||
|
||||
internal class TokenCipher {
|
||||
fun encrypt(endpoint: String, plaintext: String): String {
|
||||
return runCatching { encryptWithAlias(alias(endpoint), plaintext) }.getOrElse {
|
||||
reset(endpoint)
|
||||
encryptWithAlias(alias(endpoint), plaintext)
|
||||
}
|
||||
}
|
||||
|
||||
private fun encryptWithAlias(alias: String, plaintext: String): String {
|
||||
val cipher = Cipher.getInstance(TRANSFORMATION)
|
||||
cipher.init(Cipher.ENCRYPT_MODE, secretKey(alias))
|
||||
return listOf(
|
||||
"v1", Base64.encodeToString(cipher.iv, Base64.NO_WRAP),
|
||||
Base64.encodeToString(cipher.doFinal(plaintext.toByteArray()), Base64.NO_WRAP),
|
||||
).joinToString(":")
|
||||
}
|
||||
|
||||
fun decrypt(endpoint: String, encoded: String): String? = runCatching {
|
||||
val parts = encoded.split(":")
|
||||
require(parts.size == 3 && parts[0] == "v1")
|
||||
val cipher = Cipher.getInstance(TRANSFORMATION)
|
||||
cipher.init(
|
||||
Cipher.DECRYPT_MODE, secretKey(alias(endpoint)),
|
||||
GCMParameterSpec(128, Base64.decode(parts[1], Base64.NO_WRAP)),
|
||||
)
|
||||
String(cipher.doFinal(Base64.decode(parts[2], Base64.NO_WRAP)))
|
||||
}.getOrNull()
|
||||
|
||||
fun reset(endpoint: String) {
|
||||
val store = KeyStore.getInstance("AndroidKeyStore").apply { load(null) }
|
||||
if (store.containsAlias(alias(endpoint))) store.deleteEntry(alias(endpoint))
|
||||
}
|
||||
|
||||
private fun alias(endpoint: String): String {
|
||||
val digest = MessageDigest.getInstance("SHA-256").digest(endpoint.toByteArray())
|
||||
return "affine-auth-session-${Base64.encodeToString(digest, Base64.NO_WRAP or Base64.URL_SAFE).take(24)}"
|
||||
}
|
||||
|
||||
private fun secretKey(alias: String): SecretKey {
|
||||
val store = KeyStore.getInstance("AndroidKeyStore").apply { load(null) }
|
||||
(store.getEntry(alias, null) as? KeyStore.SecretKeyEntry)?.let { return it.secretKey }
|
||||
val generator = KeyGenerator.getInstance(KeyProperties.KEY_ALGORITHM_AES, "AndroidKeyStore")
|
||||
generator.init(
|
||||
KeyGenParameterSpec.Builder(alias, KeyProperties.PURPOSE_ENCRYPT or KeyProperties.PURPOSE_DECRYPT)
|
||||
.setBlockModes(KeyProperties.BLOCK_MODE_GCM)
|
||||
.setEncryptionPaddings(KeyProperties.ENCRYPTION_PADDING_NONE)
|
||||
.setRandomizedEncryptionRequired(true)
|
||||
.build(),
|
||||
)
|
||||
return generator.generateKey()
|
||||
}
|
||||
|
||||
companion object { private const val TRANSFORMATION = "AES/GCM/NoPadding" }
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
package app.affine.pro.plugin
|
||||
|
||||
import com.getcapacitor.JSObject
|
||||
import com.getcapacitor.Plugin
|
||||
import com.getcapacitor.PluginCall
|
||||
import com.getcapacitor.PluginMethod
|
||||
import com.getcapacitor.annotation.CapacitorPlugin
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import timber.log.Timber
|
||||
import uniffi.affine_mobile_native.hashcashMint
|
||||
|
||||
@CapacitorPlugin(name = "HashCash")
|
||||
class HashCashPlugin : Plugin() {
|
||||
|
||||
@PluginMethod
|
||||
fun hash(call: PluginCall) {
|
||||
launch(Dispatchers.IO) {
|
||||
val challenge = call.getString("challenge") ?: ""
|
||||
val bits = call.getInt("bits") ?: 20
|
||||
val hash = hashcashMint(resource = challenge, bits = bits.toUInt())
|
||||
Timber.i("hash:[ value = $hash ]")
|
||||
call.resolve(JSObject().apply {
|
||||
put("value", hash)
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
package app.affine.pro.plugin
|
||||
|
||||
import androidx.activity.BackEventCompat
|
||||
import androidx.activity.OnBackPressedCallback
|
||||
import com.getcapacitor.JSObject
|
||||
import com.getcapacitor.Plugin
|
||||
import com.getcapacitor.PluginCall
|
||||
import com.getcapacitor.PluginMethod
|
||||
import com.getcapacitor.annotation.CapacitorPlugin
|
||||
|
||||
@CapacitorPlugin(name = "MobileBack")
|
||||
class MobileBackPlugin : Plugin() {
|
||||
private val callback = object : OnBackPressedCallback(false) {
|
||||
override fun handleOnBackStarted(backEvent: BackEventCompat) {
|
||||
emit("begin", backEvent.progress)
|
||||
}
|
||||
|
||||
override fun handleOnBackProgressed(backEvent: BackEventCompat) {
|
||||
emit("progress", backEvent.progress)
|
||||
}
|
||||
|
||||
override fun handleOnBackCancelled() {
|
||||
emit("cancel")
|
||||
}
|
||||
|
||||
override fun handleOnBackPressed() {
|
||||
emit("commit")
|
||||
}
|
||||
}
|
||||
|
||||
override fun load() {
|
||||
activity.onBackPressedDispatcher.addCallback(activity, callback)
|
||||
}
|
||||
|
||||
@PluginMethod
|
||||
fun setEnabled(call: PluginCall) {
|
||||
callback.isEnabled = call.getBoolean("enabled", false) ?: false
|
||||
call.resolve()
|
||||
}
|
||||
|
||||
private fun emit(phase: String, progress: Float? = null) {
|
||||
notifyListeners("back", JSObject().apply {
|
||||
put("phase", phase)
|
||||
progress?.let { put("progress", it) }
|
||||
})
|
||||
}
|
||||
|
||||
override fun handleOnDestroy() {
|
||||
callback.remove()
|
||||
super.handleOnDestroy()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,832 @@
|
||||
package app.affine.pro.plugin
|
||||
|
||||
import com.getcapacitor.JSArray
|
||||
import com.getcapacitor.JSObject
|
||||
import com.getcapacitor.Plugin
|
||||
import com.getcapacitor.PluginCall
|
||||
import com.getcapacitor.PluginMethod
|
||||
import com.getcapacitor.annotation.CapacitorPlugin
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import timber.log.Timber
|
||||
import uniffi.affine_mobile_native.DocRecord
|
||||
import uniffi.affine_mobile_native.DocIndexedClock
|
||||
import uniffi.affine_mobile_native.IndexHit
|
||||
import uniffi.affine_mobile_native.SetBlob
|
||||
import uniffi.affine_mobile_native.newDocStoragePool
|
||||
|
||||
private const val ANDROID_INDEXER_VERSION_OFFSET = 1u
|
||||
|
||||
@CapacitorPlugin(name = "NbStoreDocStorage")
|
||||
class NbStorePlugin : Plugin() {
|
||||
|
||||
private val docStoragePool by lazy {
|
||||
newDocStoragePool()
|
||||
}
|
||||
|
||||
@PluginMethod
|
||||
fun connect(call: PluginCall) {
|
||||
launch(Dispatchers.IO) {
|
||||
try {
|
||||
val id = call.getStringEnsure("id")
|
||||
val spaceId = call.getStringEnsure("spaceId")
|
||||
val spaceType = call.getStringEnsure("spaceType")
|
||||
val peer = call.getStringEnsure("peer")
|
||||
val appStoragePath = activity?.filesDir ?: run {
|
||||
Timber.w("Failed to connect storage, cannot access device file system.")
|
||||
call.reject("Failed to connect storage, cannot access device file system.")
|
||||
return@launch
|
||||
}
|
||||
val peerDir = appStoragePath.resolve("workspaces")
|
||||
.resolve(spaceType)
|
||||
.resolve(
|
||||
peer.replace(Regex("[/!@#$%^&*()+~`\"':;,?<>|]"), "_")
|
||||
.replace(Regex("_+"), "_")
|
||||
.replace(Regex("_+$"), "")
|
||||
)
|
||||
Timber.i("NbStore connecting... peerDir[$peerDir].")
|
||||
peerDir.mkdirs()
|
||||
val db = peerDir.resolve("$spaceId.db")
|
||||
docStoragePool.connect(id, db.path)
|
||||
Timber.i("NbStore connected [ id = $id ].")
|
||||
call.resolve()
|
||||
} catch (e: Exception) {
|
||||
Timber.e(e, "Failed to connect NbStore.")
|
||||
call.reject("Failed to connect NbStore.", e)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@PluginMethod
|
||||
fun disconnect(call: PluginCall) {
|
||||
launch(Dispatchers.IO) {
|
||||
try {
|
||||
val id = call.getStringEnsure("id")
|
||||
docStoragePool.disconnect(universalId = id)
|
||||
Timber.i("NbStore disconnected [ id = $id ].")
|
||||
call.resolve()
|
||||
} catch (e: Exception) {
|
||||
Timber.e(e, "Failed to disconnect NbStore")
|
||||
call.reject("Failed to disconnect NbStore", null, e)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@PluginMethod
|
||||
fun setSpaceId(call: PluginCall) {
|
||||
launch(Dispatchers.IO) {
|
||||
try {
|
||||
val id = call.getStringEnsure("id")
|
||||
val spaceId = call.getStringEnsure("spaceId")
|
||||
docStoragePool.setSpaceId(universalId = id, spaceId = spaceId)
|
||||
Timber.i("Set space id: [ id = $id, spaceId = $spaceId ].")
|
||||
call.resolve()
|
||||
} catch (e: Exception) {
|
||||
Timber.e(e, "Failed to set space id.")
|
||||
call.reject("Failed to set space id, ${e.message}", null, e)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@PluginMethod
|
||||
fun pushUpdate(call: PluginCall) {
|
||||
launch(Dispatchers.IO) {
|
||||
try {
|
||||
val id = call.getStringEnsure("id")
|
||||
val docId = call.getStringEnsure("docId")
|
||||
val data = call.getStringEnsure("data")
|
||||
val timestamp = docStoragePool.pushUpdate(
|
||||
universalId = id,
|
||||
docId = docId,
|
||||
update = data
|
||||
)
|
||||
call.resolve(JSObject().put("timestamp", timestamp))
|
||||
} catch (e: Exception) {
|
||||
call.reject("Failed to push update, ${e.message}", null, e)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@PluginMethod
|
||||
fun getDocSnapshot(call: PluginCall) {
|
||||
launch(Dispatchers.IO) {
|
||||
try {
|
||||
val id = call.getStringEnsure("id")
|
||||
val docId = call.getStringEnsure("docId")
|
||||
val record = docStoragePool.getDocSnapshot(universalId = id, docId = docId)
|
||||
record?.let {
|
||||
call.resolve(
|
||||
JSObject()
|
||||
.put("docId", it.docId)
|
||||
.put("bin", it.bin)
|
||||
.put("timestamp", it.timestamp)
|
||||
)
|
||||
} ?: call.resolve()
|
||||
} catch (e: Exception) {
|
||||
call.reject("Failed to get doc snapshot, ${e.message}", null, e)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@PluginMethod
|
||||
fun setDocSnapshot(call: PluginCall) {
|
||||
launch(Dispatchers.IO) {
|
||||
try {
|
||||
val id = call.getStringEnsure("id")
|
||||
val docId = call.getStringEnsure("docId")
|
||||
val bin = call.getStringEnsure("bin")
|
||||
val timestamp = call.getLongEnsure("timestamp")
|
||||
val success = docStoragePool.setDocSnapshot(
|
||||
universalId = id,
|
||||
snapshot = DocRecord(docId, bin, timestamp)
|
||||
)
|
||||
call.resolve(JSObject().put("success", success))
|
||||
} catch (e: Exception) {
|
||||
call.reject("Failed to set doc snapshot, ${e.message}", null, e)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@PluginMethod
|
||||
fun getDocUpdates(call: PluginCall) {
|
||||
launch(Dispatchers.IO) {
|
||||
try {
|
||||
val id = call.getStringEnsure("id")
|
||||
val docId = call.getStringEnsure("docId")
|
||||
val updates = docStoragePool.getDocUpdates(universalId = id, docId = docId)
|
||||
val mapped = JSArray(updates.map {
|
||||
JSObject()
|
||||
.put("docId", it.docId)
|
||||
.put("timestamp", it.timestamp)
|
||||
.put("bin", it.bin)
|
||||
})
|
||||
call.resolve(JSObject().put("updates", mapped))
|
||||
} catch (e: Exception) {
|
||||
call.reject("Failed to get doc updates, ${e.message}", null, e)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@PluginMethod
|
||||
fun markUpdatesMerged(call: PluginCall) {
|
||||
launch(Dispatchers.IO) {
|
||||
try {
|
||||
val id = call.getStringEnsure("id")
|
||||
val docId = call.getStringEnsure("docId")
|
||||
val times = call.getListEnsure<Long>("timestamps")
|
||||
val count = docStoragePool.markUpdatesMerged(
|
||||
universalId = id,
|
||||
docId = docId,
|
||||
updates = times
|
||||
)
|
||||
call.resolve(JSObject().put("count", count))
|
||||
} catch (e: Exception) {
|
||||
call.reject("Failed to mark updates merged, ${e.message}", null, e)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@PluginMethod
|
||||
fun deleteDoc(call: PluginCall) {
|
||||
launch(Dispatchers.IO) {
|
||||
try {
|
||||
val id = call.getStringEnsure("id")
|
||||
val docId = call.getStringEnsure("docId")
|
||||
docStoragePool.deleteDoc(universalId = id, docId = docId)
|
||||
call.resolve()
|
||||
} catch (e: Exception) {
|
||||
call.reject("Failed to delete doc: ${e.message}", null, e)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@PluginMethod
|
||||
fun getDocClocks(call: PluginCall) {
|
||||
launch(Dispatchers.IO) {
|
||||
try {
|
||||
val id = call.getStringEnsure("id")
|
||||
val after = call.getLong("after")
|
||||
val docClocks = docStoragePool.getDocClocks(
|
||||
universalId = id,
|
||||
after = after,
|
||||
)
|
||||
val mapped = JSArray(docClocks.map {
|
||||
JSObject()
|
||||
.put("docId", it.docId)
|
||||
.put("timestamp", it.timestamp)
|
||||
})
|
||||
call.resolve(JSObject().put("clocks", mapped))
|
||||
} catch (e: Exception) {
|
||||
call.reject("Failed to get doc clocks: ${e.message}", null, e)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@PluginMethod
|
||||
fun getDocClock(call: PluginCall) {
|
||||
launch(Dispatchers.IO) {
|
||||
try {
|
||||
val id = call.getStringEnsure("id")
|
||||
val docId = call.getStringEnsure("docId")
|
||||
val docClock = docStoragePool.getDocClock(universalId = id, docId = docId)
|
||||
docClock?.let {
|
||||
call.resolve(
|
||||
JSObject()
|
||||
.put("docId", it.docId)
|
||||
.put("timestamp", it.timestamp)
|
||||
)
|
||||
} ?: call.resolve()
|
||||
} catch (e: Exception) {
|
||||
call.reject("Failed to get doc clock: ${e.message}", null, e)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@PluginMethod
|
||||
fun getBlob(call: PluginCall) {
|
||||
launch(Dispatchers.IO) {
|
||||
try {
|
||||
val id = call.getStringEnsure("id")
|
||||
val key = call.getStringEnsure("key")
|
||||
val blob = docStoragePool.getBlob(universalId = id, key = key)
|
||||
blob?.let {
|
||||
call.resolve(
|
||||
JSObject()
|
||||
.put("key", it.key)
|
||||
.put("data", it.data)
|
||||
.put("mime", it.mime)
|
||||
.put("size", it.size)
|
||||
.put("createdAt", it.createdAt)
|
||||
)
|
||||
} ?: call.resolve()
|
||||
} catch (e: Exception) {
|
||||
call.reject("Failed to get blob: ${e.message}", null, e)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@PluginMethod
|
||||
fun setBlob(call: PluginCall) {
|
||||
launch(Dispatchers.IO) {
|
||||
try {
|
||||
val id = call.getStringEnsure("id")
|
||||
val key = call.getStringEnsure("key")
|
||||
val data = call.getStringEnsure("data")
|
||||
val mime = call.getStringEnsure("mime")
|
||||
docStoragePool.setBlob(universalId = id, blob = SetBlob(key, data, mime))
|
||||
call.resolve()
|
||||
} catch (e: Exception) {
|
||||
call.reject("Failed to set blob: ${e.message}", null, e)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@PluginMethod
|
||||
fun deleteBlob(call: PluginCall) {
|
||||
launch(Dispatchers.IO) {
|
||||
try {
|
||||
val id = call.getStringEnsure("id")
|
||||
val key = call.getStringEnsure("key")
|
||||
val permanently = call.getBoolean("permanently") ?: false
|
||||
docStoragePool.deleteBlob(universalId = id, key = key, permanently = permanently)
|
||||
call.resolve()
|
||||
} catch (e: Exception) {
|
||||
call.reject("Failed to delete blob: ${e.message}", null, e)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@PluginMethod
|
||||
fun releaseBlobs(call: PluginCall) {
|
||||
launch(Dispatchers.IO) {
|
||||
try {
|
||||
val id = call.getStringEnsure("id")
|
||||
docStoragePool.releaseBlobs(universalId = id)
|
||||
call.resolve()
|
||||
} catch (e: Exception) {
|
||||
call.reject("Failed to release blobs: ${e.message}", null, e)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@PluginMethod
|
||||
fun listBlobs(call: PluginCall) {
|
||||
launch(Dispatchers.IO) {
|
||||
try {
|
||||
val id = call.getStringEnsure("id")
|
||||
val blobs = docStoragePool.listBlobs(universalId = id)
|
||||
val mapped = JSArray(blobs.map {
|
||||
JSObject()
|
||||
.put("key", it.key)
|
||||
.put("size", it.size)
|
||||
.put("mime", it.mime)
|
||||
.put("createdAt", it.createdAt)
|
||||
})
|
||||
call.resolve(JSObject().put("blobs", mapped))
|
||||
} catch (e: Exception) {
|
||||
call.reject("Failed to list blobs: ${e.message}", null, e)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@PluginMethod
|
||||
fun getPeerRemoteClocks(call: PluginCall) {
|
||||
launch(Dispatchers.IO) {
|
||||
try {
|
||||
val id = call.getStringEnsure("id")
|
||||
val peer = call.getStringEnsure("peer")
|
||||
val clocks = docStoragePool.getPeerRemoteClocks(
|
||||
universalId = id,
|
||||
peer = peer,
|
||||
)
|
||||
val mapped = JSArray(clocks.map {
|
||||
JSObject()
|
||||
.put("docId", it.docId)
|
||||
.put("timestamp", it.timestamp)
|
||||
})
|
||||
call.resolve(JSObject().put("clocks", mapped))
|
||||
} catch (e: Exception) {
|
||||
call.reject("Failed to get peer remote clocks: ${e.message}", null, e)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@PluginMethod
|
||||
fun getPeerRemoteClock(call: PluginCall) {
|
||||
launch(Dispatchers.IO) {
|
||||
try {
|
||||
val id = call.getStringEnsure("id")
|
||||
val peer = call.getStringEnsure("peer")
|
||||
val docId = call.getStringEnsure("docId")
|
||||
val clock = docStoragePool.getPeerRemoteClock(
|
||||
universalId = id,
|
||||
peer = peer,
|
||||
docId = docId,
|
||||
)
|
||||
clock?.let {
|
||||
call.resolve(
|
||||
JSObject()
|
||||
.put("docId", it.docId)
|
||||
.put("timestamp", it.timestamp)
|
||||
)
|
||||
} ?: call.resolve()
|
||||
} catch (e: Exception) {
|
||||
call.reject("Failed to get peer remote clock: ${e.message}", null, e)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@PluginMethod
|
||||
fun setPeerRemoteClock(call: PluginCall) {
|
||||
launch(Dispatchers.IO) {
|
||||
try {
|
||||
val id = call.getStringEnsure("id")
|
||||
val peer = call.getStringEnsure("peer")
|
||||
val docId = call.getStringEnsure("docId")
|
||||
val timestamp = call.getLongEnsure("timestamp")
|
||||
docStoragePool.setPeerRemoteClock(
|
||||
universalId = id,
|
||||
peer = peer,
|
||||
docId = docId,
|
||||
clock = timestamp,
|
||||
)
|
||||
call.resolve()
|
||||
} catch (e: Exception) {
|
||||
call.reject("Failed to set peer remote clock: ${e.message}", null, e)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@PluginMethod
|
||||
fun getPeerPulledRemoteClocks(call: PluginCall) {
|
||||
launch(Dispatchers.IO) {
|
||||
try {
|
||||
val id = call.getStringEnsure("id")
|
||||
val peer = call.getStringEnsure("peer")
|
||||
val clocks = docStoragePool.getPeerPulledRemoteClocks(
|
||||
universalId = id,
|
||||
peer = peer,
|
||||
)
|
||||
val mapped = JSArray(clocks.map {
|
||||
JSObject()
|
||||
.put("docId", it.docId)
|
||||
.put("timestamp", it.timestamp)
|
||||
})
|
||||
call.resolve(JSObject().put("clocks", mapped))
|
||||
} catch (e: Exception) {
|
||||
call.reject("Failed to get peer pulled remote clocks: ${e.message}", null, e)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@PluginMethod
|
||||
fun getPeerPulledRemoteClock(call: PluginCall) {
|
||||
launch(Dispatchers.IO) {
|
||||
try {
|
||||
val id = call.getStringEnsure("id")
|
||||
val peer = call.getStringEnsure("peer")
|
||||
val docId = call.getStringEnsure("docId")
|
||||
val clock = docStoragePool.getPeerPulledRemoteClock(
|
||||
universalId = id,
|
||||
peer = peer,
|
||||
docId = docId,
|
||||
)
|
||||
clock?.let {
|
||||
call.resolve(
|
||||
JSObject()
|
||||
.put("docId", it.docId)
|
||||
.put("timestamp", it.timestamp)
|
||||
)
|
||||
} ?: call.resolve()
|
||||
} catch (e: Exception) {
|
||||
call.reject("Failed to get peer pulled remote clock: ${e.message}", null, e)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@PluginMethod
|
||||
fun setPeerPulledRemoteClock(call: PluginCall) {
|
||||
launch(Dispatchers.IO) {
|
||||
try {
|
||||
val id = call.getStringEnsure("id")
|
||||
val peer = call.getStringEnsure("peer")
|
||||
val docId = call.getStringEnsure("docId")
|
||||
val timestamp = call.getLongEnsure("timestamp")
|
||||
docStoragePool.setPeerPulledRemoteClock(
|
||||
universalId = id,
|
||||
peer = peer,
|
||||
docId = docId,
|
||||
clock = timestamp,
|
||||
)
|
||||
call.resolve()
|
||||
} catch (e: Exception) {
|
||||
call.reject("Failed to set peer pulled remote clock: ${e.message}", null, e)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@PluginMethod
|
||||
fun getPeerPushedClocks(call: PluginCall) {
|
||||
launch(Dispatchers.IO) {
|
||||
try {
|
||||
val id = call.getStringEnsure("id")
|
||||
val peer = call.getStringEnsure("peer")
|
||||
val clocks = docStoragePool.getPeerPushedClocks(
|
||||
universalId = id,
|
||||
peer = peer,
|
||||
)
|
||||
val mapped = JSArray(clocks.map {
|
||||
JSObject()
|
||||
.put("docId", it.docId)
|
||||
.put("timestamp", it.timestamp)
|
||||
})
|
||||
call.resolve(JSObject().put("clocks", mapped))
|
||||
} catch (e: Exception) {
|
||||
call.reject("Failed to get peer pushed clocks: ${e.message}", null, e)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@PluginMethod
|
||||
fun getPeerPushedClock(call: PluginCall) {
|
||||
launch(Dispatchers.IO) {
|
||||
try {
|
||||
val id = call.getStringEnsure("id")
|
||||
val peer = call.getStringEnsure("peer")
|
||||
val docId = call.getStringEnsure("docId")
|
||||
val clock = docStoragePool.getPeerPushedClock(
|
||||
universalId = id,
|
||||
peer = peer,
|
||||
docId = docId,
|
||||
)
|
||||
clock?.let {
|
||||
call.resolve(
|
||||
JSObject()
|
||||
.put("docId", it.docId)
|
||||
.put("timestamp", it.timestamp)
|
||||
)
|
||||
} ?: call.resolve()
|
||||
} catch (e: Exception) {
|
||||
call.reject("Failed to get peer pushed clock: ${e.message}", null, e)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@PluginMethod
|
||||
fun setPeerPushedClock(call: PluginCall) {
|
||||
launch(Dispatchers.IO) {
|
||||
try {
|
||||
val id = call.getStringEnsure("id")
|
||||
val peer = call.getStringEnsure("peer")
|
||||
val docId = call.getStringEnsure("docId")
|
||||
val timestamp = call.getLongEnsure("timestamp")
|
||||
docStoragePool.setPeerPushedClock(
|
||||
universalId = id,
|
||||
peer = peer,
|
||||
docId = docId,
|
||||
clock = timestamp,
|
||||
)
|
||||
call.resolve()
|
||||
} catch (e: Exception) {
|
||||
call.reject("Failed to set peer pushed clock: ${e.message}", null, e)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@PluginMethod
|
||||
fun getBlobUploadedAt(call: PluginCall) {
|
||||
launch(Dispatchers.IO) {
|
||||
try {
|
||||
val id = call.getStringEnsure("id")
|
||||
val peer = call.getStringEnsure("peer")
|
||||
val blobId = call.getStringEnsure("blobId")
|
||||
val uploadedAt = docStoragePool.getBlobUploadedAt(
|
||||
universalId = id,
|
||||
peer = peer,
|
||||
blobId = blobId,
|
||||
)
|
||||
uploadedAt?.let {
|
||||
call.resolve(JSObject().put("uploadedAt", it))
|
||||
} ?: call.resolve()
|
||||
} catch (e: Exception) {
|
||||
call.reject("Failed to get blob uploaded: ${e.message}", null, e)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@PluginMethod
|
||||
fun setBlobUploadedAt(call: PluginCall) {
|
||||
launch(Dispatchers.IO) {
|
||||
try {
|
||||
val id = call.getStringEnsure("id")
|
||||
val peer = call.getStringEnsure("peer")
|
||||
val blobId = call.getStringEnsure("blobId")
|
||||
val uploadedAt = call.getLong("uploadedAt")
|
||||
docStoragePool.setBlobUploadedAt(
|
||||
universalId = id,
|
||||
peer = peer,
|
||||
blobId = blobId,
|
||||
uploadedAt = uploadedAt,
|
||||
)
|
||||
call.resolve()
|
||||
} catch (e: Exception) {
|
||||
call.reject("Failed to set blob uploaded: ${e.message}", null, e)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@PluginMethod
|
||||
fun clearClocks(call: PluginCall) {
|
||||
launch(Dispatchers.IO) {
|
||||
try {
|
||||
val id = call.getStringEnsure("id")
|
||||
docStoragePool.clearClocks(universalId = id)
|
||||
call.resolve()
|
||||
} catch (e: Exception) {
|
||||
call.reject("Failed to clear clocks: ${e.message}", null, e)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@PluginMethod
|
||||
fun crawlDocData(call: PluginCall) {
|
||||
launch(Dispatchers.IO) {
|
||||
try {
|
||||
val id = call.getStringEnsure("id")
|
||||
val docId = call.getStringEnsure("docId")
|
||||
val result = docStoragePool.crawlDocData(
|
||||
universalId = id,
|
||||
docId = docId,
|
||||
)
|
||||
val blocks = JSArray(
|
||||
result.blocks.map { block ->
|
||||
JSObject()
|
||||
.put("blockId", block.blockId)
|
||||
.put("flavour", block.flavour)
|
||||
.put("content", block.content?.let(::JSArray))
|
||||
.put("blob", block.blob?.let(::JSArray))
|
||||
.put("refDocId", block.refDocId?.let(::JSArray))
|
||||
.put("refInfo", block.refInfo?.let(::JSArray))
|
||||
.put("parentFlavour", block.parentFlavour)
|
||||
.put("parentBlockId", block.parentBlockId)
|
||||
.put("additional", block.additional)
|
||||
}
|
||||
)
|
||||
call.resolve(
|
||||
JSObject()
|
||||
.put("title", result.title)
|
||||
.put("blocks", blocks)
|
||||
.put("summary", result.summary)
|
||||
)
|
||||
} catch (e: Exception) {
|
||||
call.reject("Failed to crawl doc data: ${e.message}", null, e)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@PluginMethod
|
||||
fun getDocIndexedClock(call: PluginCall) {
|
||||
launch(Dispatchers.IO) {
|
||||
try {
|
||||
val clock = docStoragePool.getDocIndexedClock(
|
||||
call.getStringEnsure("id"),
|
||||
call.getStringEnsure("docId")
|
||||
)
|
||||
call.resolve(clock?.let(::indexedClockJson))
|
||||
} catch (e: Exception) {
|
||||
call.reject("Failed to get indexed clock: ${e.message}", null, e)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@PluginMethod
|
||||
fun setDocIndexedClock(call: PluginCall) {
|
||||
launch(Dispatchers.IO) {
|
||||
try {
|
||||
val clock = DocIndexedClock(
|
||||
call.getStringEnsure("docId"),
|
||||
call.getLong("indexedClock") ?: throw IllegalArgumentException("indexedClock is required"),
|
||||
call.getLong("indexerVersion") ?: throw IllegalArgumentException("indexerVersion is required")
|
||||
)
|
||||
docStoragePool.setDocIndexedClock(call.getStringEnsure("id"), clock)
|
||||
call.resolve()
|
||||
} catch (e: Exception) {
|
||||
call.reject("Failed to set indexed clock: ${e.message}", null, e)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@PluginMethod
|
||||
fun setDocIndexedClocks(call: PluginCall) {
|
||||
launch(Dispatchers.IO) {
|
||||
try {
|
||||
val values = call.getArray("clocks") ?: throw IllegalArgumentException("clocks is required")
|
||||
val clocks = (0 until values.length()).map { index ->
|
||||
val value = values.getJSONObject(index)
|
||||
DocIndexedClock(
|
||||
value.getString("docId"),
|
||||
value.getLong("timestamp"),
|
||||
value.getLong("indexerVersion")
|
||||
)
|
||||
}
|
||||
docStoragePool.setDocIndexedClocks(call.getStringEnsure("id"), clocks)
|
||||
call.resolve()
|
||||
} catch (e: Exception) {
|
||||
call.reject("Failed to commit indexed clocks: ${e.message}", null, e)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@PluginMethod
|
||||
fun clearDocIndexedClock(call: PluginCall) {
|
||||
launch(Dispatchers.IO) {
|
||||
try {
|
||||
docStoragePool.clearDocIndexedClock(call.getStringEnsure("id"), call.getStringEnsure("docId"))
|
||||
call.resolve()
|
||||
} catch (e: Exception) {
|
||||
call.reject("Failed to clear indexed clock: ${e.message}", null, e)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@PluginMethod
|
||||
fun indexUpsert(call: PluginCall) {
|
||||
launch(Dispatchers.IO) {
|
||||
try {
|
||||
val id = call.getStringEnsure("id")
|
||||
val table = call.getStringEnsure("table")
|
||||
val document = call.getObject("document")
|
||||
?: throw IllegalArgumentException("document is required")
|
||||
docStoragePool.indexUpsert(id, table, document.toString())
|
||||
call.resolve()
|
||||
} catch (e: Exception) {
|
||||
call.reject("Failed to upsert index document: ${e.message}", null, e)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@PluginMethod
|
||||
fun indexDelete(call: PluginCall) {
|
||||
launch(Dispatchers.IO) {
|
||||
try {
|
||||
val id = call.getStringEnsure("id")
|
||||
val table = call.getStringEnsure("table")
|
||||
val docId = call.getStringEnsure("docId")
|
||||
docStoragePool.indexDelete(id, table, docId)
|
||||
call.resolve()
|
||||
} catch (e: Exception) {
|
||||
call.reject("Failed to delete index document: ${e.message}", null, e)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@PluginMethod
|
||||
fun indexSearch(call: PluginCall) {
|
||||
launch(Dispatchers.IO) {
|
||||
try {
|
||||
val id = call.getStringEnsure("id")
|
||||
val table = call.getStringEnsure("table")
|
||||
val query = call.getObject("query") ?: throw IllegalArgumentException("query is required")
|
||||
val options = call.getObject("options") ?: throw IllegalArgumentException("options is required")
|
||||
val result = docStoragePool.indexSearch(id, table, query.toString(), options.toString())
|
||||
call.resolve(
|
||||
JSObject()
|
||||
.put("total", result.total.toInt())
|
||||
.put("hits", JSArray(result.hits.map(::indexHitJson)))
|
||||
)
|
||||
} catch (e: Exception) {
|
||||
call.reject("Failed to search index: ${e.message}", null, e)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@PluginMethod
|
||||
fun indexAggregate(call: PluginCall) {
|
||||
launch(Dispatchers.IO) {
|
||||
try {
|
||||
val id = call.getStringEnsure("id")
|
||||
val table = call.getStringEnsure("table")
|
||||
val query = call.getObject("query") ?: throw IllegalArgumentException("query is required")
|
||||
val hits = call.getObject("hits")?.toString()
|
||||
val result = docStoragePool.indexAggregate(
|
||||
id,
|
||||
table,
|
||||
query.toString(),
|
||||
call.getStringEnsure("field"),
|
||||
call.getIntEnsure("limit").toUInt(),
|
||||
call.getIntEnsure("offset").toUInt(),
|
||||
hits
|
||||
)
|
||||
val buckets = result.buckets.map {
|
||||
JSObject()
|
||||
.put("key", it.key)
|
||||
.put("count", it.count.toInt())
|
||||
.put("score", it.score)
|
||||
.put("hits", JSArray(it.hits.map(::indexHitJson)))
|
||||
}
|
||||
call.resolve(JSObject().put("total", result.total.toInt()).put("buckets", JSArray(buckets)))
|
||||
} catch (e: Exception) {
|
||||
call.reject("Failed to aggregate index: ${e.message}", null, e)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@PluginMethod
|
||||
fun indexDeleteByQuery(call: PluginCall) {
|
||||
launch(Dispatchers.IO) {
|
||||
try {
|
||||
val id = call.getStringEnsure("id")
|
||||
val table = call.getStringEnsure("table")
|
||||
val query = call.getObject("query") ?: throw IllegalArgumentException("query is required")
|
||||
val deleted = docStoragePool.indexDeleteByQuery(id, table, query.toString())
|
||||
call.resolve(JSObject().put("deleted", deleted.toInt()))
|
||||
} catch (e: Exception) {
|
||||
call.reject("Failed to delete index documents: ${e.message}", null, e)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@PluginMethod
|
||||
fun indexFlush(call: PluginCall) {
|
||||
launch(Dispatchers.IO) {
|
||||
try {
|
||||
docStoragePool.indexFlush(call.getStringEnsure("id"))
|
||||
call.resolve()
|
||||
} catch (e: Exception) {
|
||||
call.reject("Failed to flush index: ${e.message}", null, e)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@PluginMethod
|
||||
fun indexVersion(call: PluginCall) {
|
||||
launch(Dispatchers.IO) {
|
||||
try {
|
||||
val version = docStoragePool.indexVersion() + ANDROID_INDEXER_VERSION_OFFSET
|
||||
call.resolve(JSObject().put("indexVersion", version))
|
||||
} catch (e: Exception) {
|
||||
call.reject("Failed to get index version: ${e.message}", null, e)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun indexHitJson(hit: IndexHit): JSObject {
|
||||
val fields = hit.fields.map { JSObject().put("field", it.field).put("values", JSArray(it.values)) }
|
||||
val highlights = hit.highlights.map { highlight ->
|
||||
val values = highlight.values.map { value ->
|
||||
val spans = value.spans.map { JSObject().put("start", it.start.toInt()).put("end", it.end.toInt()) }
|
||||
JSObject().put("valueIndex", value.valueIndex.toInt()).put("spans", JSArray(spans))
|
||||
}
|
||||
JSObject().put("field", highlight.field).put("values", JSArray(values))
|
||||
}
|
||||
return JSObject()
|
||||
.put("id", hit.id)
|
||||
.put("score", hit.score)
|
||||
.put("fields", JSArray(fields))
|
||||
.put("highlights", JSArray(highlights))
|
||||
}
|
||||
|
||||
private fun indexedClockJson(clock: DocIndexedClock): JSObject = JSObject()
|
||||
.put("docId", clock.docId)
|
||||
.put("timestamp", clock.timestamp)
|
||||
.put("indexerVersion", clock.indexerVersion)
|
||||
@@ -0,0 +1,32 @@
|
||||
package app.affine.pro.plugin
|
||||
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import com.getcapacitor.Plugin
|
||||
import com.getcapacitor.PluginCall
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.CoroutineStart
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlin.coroutines.CoroutineContext
|
||||
import kotlin.coroutines.EmptyCoroutineContext
|
||||
|
||||
fun Plugin.launch(
|
||||
context: CoroutineContext = EmptyCoroutineContext,
|
||||
start: CoroutineStart = CoroutineStart.DEFAULT,
|
||||
block: suspend CoroutineScope.() -> Unit
|
||||
) = activity?.lifecycleScope?.launch(context, start, block)
|
||||
|
||||
fun PluginCall.getStringEnsure(key: String): String {
|
||||
return getString(key) ?: throw IllegalArgumentException("Missing $key parameter")
|
||||
}
|
||||
|
||||
inline fun <reified T> PluginCall.getListEnsure(key: String): List<T> {
|
||||
return getArray(key)?.toList() ?: throw IllegalArgumentException("Missing $key parameter")
|
||||
}
|
||||
|
||||
fun PluginCall.getLongEnsure(key: String): Long {
|
||||
return getLong(key) ?: throw IllegalArgumentException("Missing $key parameter")
|
||||
}
|
||||
|
||||
fun PluginCall.getIntEnsure(key: String): Int {
|
||||
return getInt(key) ?: throw IllegalArgumentException("Missing $key parameter")
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
package app.affine.pro.plugin
|
||||
|
||||
import android.net.Uri
|
||||
import com.getcapacitor.JSObject
|
||||
import com.getcapacitor.Plugin
|
||||
import com.getcapacitor.PluginCall
|
||||
import com.getcapacitor.PluginMethod
|
||||
import com.getcapacitor.annotation.CapacitorPlugin
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import timber.log.Timber
|
||||
import uniffi.affine_mobile_native.renderMermaidPreviewSvg
|
||||
import uniffi.affine_mobile_native.renderTypstPreviewSvg
|
||||
import java.io.File
|
||||
|
||||
private fun JSObject.getOptionalString(key: String): String? {
|
||||
return if (has(key) && !isNull(key)) getString(key) else null
|
||||
}
|
||||
|
||||
private fun JSObject.getOptionalDouble(key: String): Double? {
|
||||
return if (has(key) && !isNull(key)) getDouble(key) else null
|
||||
}
|
||||
|
||||
private fun resolveLocalFontDir(fontUrl: String): String? {
|
||||
val uri = Uri.parse(fontUrl)
|
||||
val path = when {
|
||||
uri.scheme == null -> {
|
||||
val file = File(fontUrl)
|
||||
if (!file.isAbsolute) {
|
||||
return null
|
||||
}
|
||||
file.path
|
||||
}
|
||||
uri.scheme == "file" -> uri.path
|
||||
else -> null
|
||||
} ?: return null
|
||||
|
||||
val file = File(path)
|
||||
val directory = if (file.isDirectory) file else file.parentFile ?: return null
|
||||
return directory.absolutePath
|
||||
}
|
||||
|
||||
private fun JSObject.resolveTypstFontDirs(): List<String>? {
|
||||
if (!has("fontUrls") || isNull("fontUrls")) {
|
||||
return null
|
||||
}
|
||||
|
||||
val fontUrls = optJSONArray("fontUrls")
|
||||
?: throw IllegalArgumentException("Typst preview fontUrls must be an array of strings.")
|
||||
val fontDirs = buildList(fontUrls.length()) {
|
||||
repeat(fontUrls.length()) { index ->
|
||||
val fontUrl = fontUrls.optString(index, null)
|
||||
?: throw IllegalArgumentException("Typst preview fontUrls must be strings.")
|
||||
val fontDir = resolveLocalFontDir(fontUrl)
|
||||
?: throw IllegalArgumentException("Typst preview on mobile only supports local font file URLs or absolute font directories.")
|
||||
add(fontDir)
|
||||
}
|
||||
}
|
||||
return fontDirs.distinct()
|
||||
}
|
||||
|
||||
@CapacitorPlugin(name = "Preview")
|
||||
class PreviewPlugin : Plugin() {
|
||||
|
||||
@PluginMethod
|
||||
fun renderMermaidSvg(call: PluginCall) {
|
||||
launch(Dispatchers.IO) {
|
||||
try {
|
||||
val code = call.getStringEnsure("code")
|
||||
val options = call.getObject("options")
|
||||
val svg = renderMermaidPreviewSvg(
|
||||
code = code,
|
||||
theme = options?.getOptionalString("theme"),
|
||||
fontFamily = options?.getOptionalString("fontFamily"),
|
||||
fontSize = options?.getOptionalDouble("fontSize"),
|
||||
)
|
||||
call.resolve(JSObject().apply {
|
||||
put("svg", svg)
|
||||
})
|
||||
} catch (e: Exception) {
|
||||
Timber.e(e, "Failed to render Mermaid preview.")
|
||||
call.reject("Failed to render Mermaid preview.", null, e)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@PluginMethod
|
||||
fun renderTypstSvg(call: PluginCall) {
|
||||
launch(Dispatchers.IO) {
|
||||
try {
|
||||
val code = call.getStringEnsure("code")
|
||||
val options = call.getObject("options")
|
||||
val svg = renderTypstPreviewSvg(
|
||||
code = code,
|
||||
fontDirs = options?.resolveTypstFontDirs(),
|
||||
cacheDir = context.cacheDir.absolutePath,
|
||||
)
|
||||
call.resolve(JSObject().apply {
|
||||
put("svg", svg)
|
||||
})
|
||||
} catch (e: Exception) {
|
||||
Timber.e(e, "Failed to render Typst preview.")
|
||||
call.reject("Failed to render Typst preview.", null, e)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,145 @@
|
||||
package app.affine.pro.service
|
||||
|
||||
import app.affine.pro.Prompt
|
||||
import app.affine.pro.utils.getCurrentServerBaseUrl
|
||||
import com.affine.pro.graphql.CreateCopilotMessageMutation
|
||||
import com.affine.pro.graphql.CreateCopilotSessionWithHistoryMutation
|
||||
import com.affine.pro.graphql.GetCopilotHistoriesQuery
|
||||
import com.affine.pro.graphql.GetCopilotHistoryIdsQuery
|
||||
import com.affine.pro.graphql.GetCopilotSessionsQuery
|
||||
import com.affine.pro.graphql.type.CreateChatMessageInput
|
||||
import com.affine.pro.graphql.type.CreateChatSessionInput
|
||||
import com.affine.pro.graphql.type.PaginationInput
|
||||
import com.affine.pro.graphql.type.QueryChatHistoriesInput
|
||||
import com.apollographql.apollo.ApolloClient
|
||||
import com.apollographql.apollo.api.Mutation
|
||||
import com.apollographql.apollo.api.Optional
|
||||
import com.apollographql.apollo.api.Query
|
||||
import com.apollographql.apollo.api.Subscription
|
||||
import com.apollographql.apollo.network.okHttpClient
|
||||
import com.getcapacitor.Bridge
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
@Singleton
|
||||
class GraphQLService @Inject constructor() {
|
||||
|
||||
suspend fun getCopilotSession(workspaceId: String, docId: String) = query(
|
||||
GetCopilotSessionsQuery(
|
||||
workspaceId = workspaceId,
|
||||
docId = Optional.present(docId),
|
||||
pagination = PaginationInput(
|
||||
first = Optional.present(100)
|
||||
),
|
||||
options = Optional.present(QueryChatHistoriesInput(action = Optional.present(false)))
|
||||
)
|
||||
).mapCatching { data ->
|
||||
data.currentUser?.copilot?.chats?.paginatedCopilotChats?.edges?.map { item -> item.node.copilotChatHistory }?.find {
|
||||
it.parentSessionId == null
|
||||
}?.sessionId ?: error(ERROR_NULL_SESSION_ID)
|
||||
}
|
||||
|
||||
suspend fun createCopilotSession(
|
||||
workspaceId: String,
|
||||
docId: String,
|
||||
prompt: Prompt = Prompt.ChatWithAFFiNEAI
|
||||
) = mutation(
|
||||
CreateCopilotSessionWithHistoryMutation(
|
||||
CreateChatSessionInput(
|
||||
docId = Optional.present(docId),
|
||||
workspaceId = workspaceId,
|
||||
promptName = prompt.value
|
||||
)
|
||||
)
|
||||
).mapCatching { data ->
|
||||
data.createCopilotSessionWithHistory.copilotChatHistory.sessionId
|
||||
}
|
||||
|
||||
suspend fun getCopilotHistories(
|
||||
workspaceId: String,
|
||||
docId: String,
|
||||
sessionId: String,
|
||||
) = query(
|
||||
GetCopilotHistoriesQuery(
|
||||
workspaceId = workspaceId,
|
||||
pagination = PaginationInput(
|
||||
first = Optional.present(100)
|
||||
),
|
||||
docId = Optional.present(docId),
|
||||
)
|
||||
).mapCatching { data ->
|
||||
data.currentUser?.copilot?.chats?.paginatedCopilotChats?.edges?.map { item -> item.node.copilotChatHistory }?.firstOrNull { history ->
|
||||
history.sessionId == sessionId
|
||||
}?.messages ?: emptyList()
|
||||
}
|
||||
|
||||
suspend fun getCopilotHistoryIds(
|
||||
workspaceId: String,
|
||||
docId: String,
|
||||
sessionId: String,
|
||||
) = query(
|
||||
GetCopilotHistoryIdsQuery(
|
||||
workspaceId = workspaceId,
|
||||
docId = Optional.present(docId),
|
||||
pagination = PaginationInput(
|
||||
first = Optional.present(100)
|
||||
),
|
||||
)
|
||||
).mapCatching { data ->
|
||||
data.currentUser?.copilot?.chats?.edges?.map { item -> item.node }?.firstOrNull { history ->
|
||||
history.sessionId == sessionId
|
||||
}?.messages ?: emptyList()
|
||||
}
|
||||
|
||||
suspend fun createCopilotMessage(
|
||||
sessionId: String,
|
||||
message: String,
|
||||
) = mutation(
|
||||
CreateCopilotMessageMutation(
|
||||
CreateChatMessageInput(
|
||||
sessionId = sessionId,
|
||||
content = Optional.present(message)
|
||||
)
|
||||
)
|
||||
).mapCatching { data ->
|
||||
data.createCopilotMessage
|
||||
}
|
||||
|
||||
suspend fun updateServer(bridge: Bridge) {
|
||||
val server = bridge.getCurrentServerBaseUrl()
|
||||
if (this::_client.isInitialized && _client.newBuilder().httpServerUrl == server) return
|
||||
_client = ApolloClient.Builder().serverUrl("$server/graphql")
|
||||
.okHttpClient(OkHttp.client)
|
||||
.build()
|
||||
}
|
||||
|
||||
private lateinit var _client: ApolloClient
|
||||
|
||||
private suspend fun <D : Query.Data> query(query: Query<D>) = withContext(Dispatchers.IO) {
|
||||
runCatching {
|
||||
withContext(Dispatchers.IO) {
|
||||
_client.query(query).execute().dataOrThrow()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun <D : Mutation.Data> mutation(mutation: Mutation<D>) =
|
||||
withContext(Dispatchers.IO) {
|
||||
runCatching {
|
||||
_client.mutation(mutation).execute().dataOrThrow()
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun <D : Subscription.Data> subscription(subscription: Subscription<D>) =
|
||||
withContext(Dispatchers.IO) {
|
||||
runCatching {
|
||||
_client.subscription(subscription).execute().dataOrThrow()
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val ERROR_NULL_SESSION_ID = "null session id."
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
package app.affine.pro.service
|
||||
|
||||
import app.affine.pro.AFFiNEApp
|
||||
import app.affine.pro.CapacitorConfig
|
||||
import app.affine.pro.utils.dataStore
|
||||
import app.affine.pro.utils.del
|
||||
import app.affine.pro.utils.set
|
||||
import com.google.firebase.crashlytics.ktx.crashlytics
|
||||
import com.google.firebase.ktx.Firebase
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.MainScope
|
||||
import kotlinx.coroutines.launch
|
||||
import okhttp3.Cookie
|
||||
import okhttp3.CookieJar
|
||||
import okhttp3.HttpUrl
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.logging.HttpLoggingInterceptor
|
||||
import timber.log.Timber
|
||||
import java.util.concurrent.ConcurrentHashMap
|
||||
|
||||
object OkHttp {
|
||||
|
||||
val client = OkHttpClient.Builder()
|
||||
.cookieJar(object : CookieJar {
|
||||
|
||||
override fun loadForRequest(url: HttpUrl): List<Cookie> {
|
||||
val cookies = CookieStore.getCookies(url.host)
|
||||
Timber.d("load cookies: [ url = $url, cookies = $cookies]")
|
||||
return cookies
|
||||
}
|
||||
|
||||
override fun saveFromResponse(url: HttpUrl, cookies: List<Cookie>) {
|
||||
Timber.d("save cookies: [ url = $url, cookies = $cookies]")
|
||||
CookieStore.saveCookies(url.host, cookies)
|
||||
}
|
||||
})
|
||||
.addInterceptor {
|
||||
it.proceed(
|
||||
it.request()
|
||||
.newBuilder()
|
||||
.addHeader("x-affine-version", CapacitorConfig.getAffineVersion())
|
||||
.build()
|
||||
)
|
||||
}
|
||||
.addInterceptor(HttpLoggingInterceptor { msg ->
|
||||
Timber.d(msg)
|
||||
}.apply {
|
||||
level = HttpLoggingInterceptor.Level.BODY
|
||||
})
|
||||
.build()
|
||||
|
||||
}
|
||||
|
||||
object AuthHttp {
|
||||
val client = OkHttpClient.Builder()
|
||||
.cookieJar(CookieJar.NO_COOKIES)
|
||||
.addInterceptor {
|
||||
it.proceed(
|
||||
it.request()
|
||||
.newBuilder()
|
||||
.addHeader("x-affine-version", CapacitorConfig.getAffineVersion())
|
||||
.build()
|
||||
)
|
||||
}
|
||||
.build()
|
||||
}
|
||||
|
||||
object CookieStore {
|
||||
|
||||
const val AFFINE_SESSION = "affine_session"
|
||||
const val AFFINE_USER_ID = "affine_user_id"
|
||||
const val AFFINE_CSRF_TOKEN = "affine_csrf_token"
|
||||
|
||||
private val _cookies = ConcurrentHashMap<String, List<Cookie>>()
|
||||
|
||||
fun saveCookies(host: String, cookies: List<Cookie>) {
|
||||
_cookies[host] = cookies
|
||||
MainScope().launch(Dispatchers.IO) {
|
||||
cookies.find { it.name == AFFINE_USER_ID }?.let {
|
||||
Timber.d("Update user id [${it.value}]")
|
||||
AFFiNEApp.context().dataStore.set(host + AFFINE_USER_ID, it.toString())
|
||||
Firebase.crashlytics.setUserId(it.value)
|
||||
}
|
||||
cookies.find { it.name == AFFINE_CSRF_TOKEN }?.let {
|
||||
AFFiNEApp.context().dataStore.set(host + AFFINE_CSRF_TOKEN, it.toString())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun getCookies(host: String) = _cookies[host] ?: emptyList()
|
||||
|
||||
fun clearAuthCookies(host: String) {
|
||||
val cookies = _cookies[host] ?: emptyList()
|
||||
_cookies[host] = cookies.filter {
|
||||
it.name != AFFINE_SESSION && it.name != AFFINE_USER_ID && it.name != AFFINE_CSRF_TOKEN
|
||||
}
|
||||
MainScope().launch(Dispatchers.IO) {
|
||||
AFFiNEApp.context().dataStore.del(host + AFFINE_USER_ID)
|
||||
AFFiNEApp.context().dataStore.del(host + AFFINE_CSRF_TOKEN)
|
||||
Firebase.crashlytics.setUserId("")
|
||||
}
|
||||
}
|
||||
|
||||
fun getCookie(url: HttpUrl, name: String) = url.host
|
||||
.let { _cookies[it] }
|
||||
?.find { cookie -> cookie.name == name }
|
||||
?.value
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
package app.affine.pro.service
|
||||
|
||||
import app.affine.pro.utils.getCurrentServerBaseUrl
|
||||
import com.getcapacitor.Bridge
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.channels.awaitClose
|
||||
import kotlinx.coroutines.channels.trySendBlocking
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.callbackFlow
|
||||
import kotlinx.coroutines.flow.flowOn
|
||||
import okhttp3.Request
|
||||
import okhttp3.Response
|
||||
import okhttp3.sse.EventSource
|
||||
import okhttp3.sse.EventSourceListener
|
||||
import okhttp3.sse.EventSources
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
@Singleton
|
||||
class SSEService @Inject constructor() {
|
||||
|
||||
private lateinit var _server: String
|
||||
|
||||
suspend fun updateServer(bridge: Bridge) {
|
||||
_server = bridge.getCurrentServerBaseUrl()
|
||||
}
|
||||
|
||||
fun messageStream(sessionId: String, messageId: String) =
|
||||
"$_server/api/copilot/chat/$sessionId/stream?messageId=$messageId".eventSource()
|
||||
|
||||
data class Event(val id: String?, val type: String?, val data: String)
|
||||
|
||||
private val factory = EventSources.createFactory(OkHttp.client)
|
||||
|
||||
private fun String.eventSource(): Flow<Result<Event>> {
|
||||
val request = Request.Builder()
|
||||
.get()
|
||||
.url(this)
|
||||
.build()
|
||||
return callbackFlow<Result<Event>> {
|
||||
factory.newEventSource(request, object : EventSourceListener() {
|
||||
override fun onClosed(eventSource: EventSource) {
|
||||
channel.close()
|
||||
}
|
||||
|
||||
override fun onEvent(
|
||||
eventSource: EventSource,
|
||||
id: String?,
|
||||
type: String?,
|
||||
data: String
|
||||
) {
|
||||
trySendBlocking(Result.success(Event(id, type, data)))
|
||||
}
|
||||
|
||||
override fun onFailure(
|
||||
eventSource: EventSource,
|
||||
t: Throwable?,
|
||||
response: Response?
|
||||
) {
|
||||
trySendBlocking(Result.failure(t ?: UnknownError("Unknown sse error.")))
|
||||
channel.close(t)
|
||||
}
|
||||
})
|
||||
awaitClose()
|
||||
}.flowOn(Dispatchers.IO)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
package app.affine.pro.service
|
||||
|
||||
import app.affine.pro.utils.getCurrentDocContentInMarkdown
|
||||
import app.affine.pro.utils.getCurrentDocId
|
||||
import app.affine.pro.utils.getCurrentWorkspaceId
|
||||
import com.getcapacitor.Bridge
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
@Singleton
|
||||
class WebService @Inject constructor() {
|
||||
|
||||
suspend fun update(bridge: Bridge) {
|
||||
_workspaceId = bridge.getCurrentWorkspaceId()
|
||||
_docId = bridge.getCurrentDocId()
|
||||
_docContentInMD = bridge.getCurrentDocContentInMarkdown()
|
||||
}
|
||||
|
||||
private lateinit var _workspaceId: String
|
||||
private lateinit var _docId: String
|
||||
private lateinit var _docContentInMD: String
|
||||
|
||||
fun workspaceId() = _workspaceId
|
||||
|
||||
fun docId() = _docId
|
||||
|
||||
fun docContentInMD() = _docContentInMD
|
||||
}
|
||||
@@ -0,0 +1,344 @@
|
||||
package app.affine.pro.theme
|
||||
|
||||
import androidx.compose.runtime.Immutable
|
||||
import androidx.compose.runtime.staticCompositionLocalOf
|
||||
import androidx.compose.ui.graphics.Color
|
||||
|
||||
@Immutable
|
||||
data class AFFiNEColorScheme(
|
||||
val textPrimary: Color,
|
||||
val textSecondary: Color,
|
||||
val textDisable: Color,
|
||||
val textEmphasis: Color,
|
||||
val backgroundPrimary: Color,
|
||||
val backgroundSecondary: Color,
|
||||
val backgroundOverlayPanel: Color,
|
||||
val backgroundTertiary: Color,
|
||||
val backgroundCodeBlock: Color,
|
||||
val backgroundModal: Color,
|
||||
val backgroundSuccess: Color,
|
||||
val backgroundError: Color,
|
||||
val backgroundWarning: Color,
|
||||
val backgroundProgressing: Color,
|
||||
val iconPrimary: Color,
|
||||
val iconSecondary: Color,
|
||||
val iconTertiary: Color,
|
||||
val iconDisable: Color,
|
||||
val iconActivated: Color,
|
||||
val divider: Color,
|
||||
)
|
||||
|
||||
val affineLightScheme = AFFiNEColorScheme(
|
||||
textPrimary = AFFiNEColorTokens.Grey900,
|
||||
textSecondary = AFFiNEColorTokens.Grey600,
|
||||
textDisable = AFFiNEColorTokens.Grey400,
|
||||
textEmphasis = AFFiNEColorTokens.AFFiNE600,
|
||||
backgroundPrimary = AFFiNEColorTokens.BaseWhite,
|
||||
backgroundSecondary = AFFiNEColorTokens.Grey100,
|
||||
backgroundOverlayPanel = AFFiNEColorTokens.Grey50,
|
||||
backgroundTertiary = AFFiNEColorTokens.Grey300,
|
||||
backgroundCodeBlock = AFFiNEColorTokens.Grey50,
|
||||
backgroundModal = AFFiNEColorTokens.TransparentBlack700,
|
||||
backgroundSuccess = AFFiNEColorTokens.Emerald50,
|
||||
backgroundError = AFFiNEColorTokens.Rose50,
|
||||
backgroundWarning = AFFiNEColorTokens.Orange25,
|
||||
backgroundProgressing = AFFiNEColorTokens.Indigo50,
|
||||
iconPrimary = AFFiNEColorTokens.Grey600,
|
||||
iconSecondary = AFFiNEColorTokens.Grey400,
|
||||
iconTertiary = AFFiNEColorTokens.Grey300,
|
||||
iconDisable = AFFiNEColorTokens.Grey400,
|
||||
iconActivated = AFFiNEColorTokens.AFFiNE600,
|
||||
divider = AFFiNEColorTokens.TransparentGrey400,
|
||||
)
|
||||
|
||||
val affineDarkScheme = AFFiNEColorScheme(
|
||||
textPrimary = AFFiNEColorTokens.Grey200,
|
||||
textSecondary = AFFiNEColorTokens.Grey500,
|
||||
textDisable = AFFiNEColorTokens.Grey700,
|
||||
textEmphasis = AFFiNEColorTokens.AFFiNE500,
|
||||
backgroundPrimary = AFFiNEColorTokens.Grey950,
|
||||
backgroundSecondary = AFFiNEColorTokens.Grey900,
|
||||
backgroundOverlayPanel = AFFiNEColorTokens.Grey900,
|
||||
backgroundTertiary = AFFiNEColorTokens.Grey700,
|
||||
backgroundCodeBlock = AFFiNEColorTokens.Grey900,
|
||||
backgroundModal = AFFiNEColorTokens.TransparentBlack400,
|
||||
backgroundSuccess = AFFiNEColorTokens.Emerald950,
|
||||
backgroundError = AFFiNEColorTokens.Rose950,
|
||||
backgroundWarning = AFFiNEColorTokens.Orange950,
|
||||
backgroundProgressing = AFFiNEColorTokens.Indigo950,
|
||||
iconPrimary = AFFiNEColorTokens.Grey100,
|
||||
iconSecondary = AFFiNEColorTokens.Grey300,
|
||||
iconTertiary = AFFiNEColorTokens.Grey700,
|
||||
iconDisable = AFFiNEColorTokens.Grey800,
|
||||
iconActivated = AFFiNEColorTokens.AFFiNE600,
|
||||
divider = AFFiNEColorTokens.TransparentGrey300,
|
||||
)
|
||||
|
||||
val LocalAFFiNEColors = staticCompositionLocalOf { affineLightScheme }
|
||||
|
||||
object AFFiNEColorTokens {
|
||||
val BaseWhite = Color(0XFFFFFFFF)
|
||||
val BaseBlack = Color(0XFF000000)
|
||||
|
||||
val Grey25 = Color(0XFFF9F9F9)
|
||||
val Grey50 = Color(0XFFF5F5F5)
|
||||
val Grey100 = Color(0XFFF3F3F3)
|
||||
val Grey200 = Color(0XFFE6E6E6)
|
||||
val Grey300 = Color(0XFFCDCDCD)
|
||||
val Grey400 = Color(0XFFB3B3B3)
|
||||
val Grey500 = Color(0XFF929292)
|
||||
val Grey600 = Color(0XFF7A7A7A)
|
||||
val Grey700 = Color(0XFF565656)
|
||||
val Grey800 = Color(0XFF414141)
|
||||
val Grey900 = Color(0XFF252525)
|
||||
val Grey950 = Color(0XFF141414)
|
||||
|
||||
val Red25 = Color(0XFFFFF4F5)
|
||||
val Red50 = Color(0XFFFCE5E6)
|
||||
val Red100 = Color(0XFFFFD1D1)
|
||||
val Red200 = Color(0XFFFBB7B7)
|
||||
val Red300 = Color(0XFFFF9A9A)
|
||||
val Red400 = Color(0XFFFC7979)
|
||||
val Red500 = Color(0XFFF45252)
|
||||
val Red600 = Color(0XFFED3F3F)
|
||||
val Red700 = Color(0XFFC83030)
|
||||
val Red800 = Color(0XFF9F2D2D)
|
||||
val Red900 = Color(0XFF761717)
|
||||
val Red950 = Color(0XFF460606)
|
||||
|
||||
val Orange25 = Color(0XFFFFF7EE)
|
||||
val Orange50 = Color(0XFFFFEBD5)
|
||||
val Orange100 = Color(0XFFFFDDB5)
|
||||
val Orange200 = Color(0XFFFFD3AB)
|
||||
val Orange300 = Color(0XFFFFC58F)
|
||||
val Orange400 = Color(0XFFFFB978)
|
||||
val Orange500 = Color(0XFFFFAD63)
|
||||
val Orange600 = Color(0XFFFF8C39)
|
||||
val Orange700 = Color(0XFFF37C26)
|
||||
val Orange800 = Color(0XFFC96317)
|
||||
val Orange900 = Color(0XFFA65113)
|
||||
val Orange950 = Color(0XFF843A06)
|
||||
|
||||
val Amber25 = Color(0XFFFDFCF4)
|
||||
val Amber50 = Color(0XFFFFFBEB)
|
||||
val Amber100 = Color(0XFFFEF3C7)
|
||||
val Amber200 = Color(0XFFFCE68A)
|
||||
val Amber300 = Color(0XFFFCD34D)
|
||||
val Amber400 = Color(0XFFFABF24)
|
||||
val Amber500 = Color(0XFFF59E0A)
|
||||
val Amber600 = Color(0XFFD97705)
|
||||
val Amber700 = Color(0XFFB55309)
|
||||
val Amber800 = Color(0XFF92400F)
|
||||
val Amber900 = Color(0XFF78350F)
|
||||
val Amber950 = Color(0XFF461A02)
|
||||
|
||||
val Yellow25 = Color(0XFFFFFAEC)
|
||||
val Yellow50 = Color(0XFFFBF3D8)
|
||||
val Yellow100 = Color(0XFFFFEFB6)
|
||||
val Yellow200 = Color(0XFFFFE898)
|
||||
val Yellow300 = Color(0XFFFEE483)
|
||||
val Yellow400 = Color(0XFFFFDE6B)
|
||||
val Yellow500 = Color(0XFFFEDC61)
|
||||
val Yellow600 = Color(0XFFFFD337)
|
||||
val Yellow700 = Color(0XFFE0AA00)
|
||||
val Yellow800 = Color(0XFFAC7400)
|
||||
val Yellow900 = Color(0XFF704200)
|
||||
val Yellow950 = Color(0XFF321A04)
|
||||
|
||||
val Lime25 = Color(0XFFFBFEF1)
|
||||
val Lime50 = Color(0XFFF9FEE7)
|
||||
val Lime100 = Color(0XFFF0FCCB)
|
||||
val Lime200 = Color(0XFFE0F99D)
|
||||
val Lime300 = Color(0XFFC9F263)
|
||||
val Lime400 = Color(0XFFB2E634)
|
||||
val Lime500 = Color(0XFF92CC16)
|
||||
val Lime600 = Color(0XFF72A10E)
|
||||
val Lime700 = Color(0XFF577C0E)
|
||||
val Lime800 = Color(0XFF466213)
|
||||
val Lime900 = Color(0XFF3B5314)
|
||||
val Lime950 = Color(0XFF1A2E06)
|
||||
|
||||
val Green25 = Color(0XFFEFFFEC)
|
||||
val Green50 = Color(0XFFDCFDD7)
|
||||
val Green100 = Color(0XFFC9F8C1)
|
||||
val Green200 = Color(0XFFB8F0AF)
|
||||
val Green300 = Color(0XFFA8E79C)
|
||||
val Green400 = Color(0XFF9CDA91)
|
||||
val Green500 = Color(0XFF91CE87)
|
||||
val Green600 = Color(0XFF7CC270)
|
||||
val Green700 = Color(0XFF64BA56)
|
||||
val Green800 = Color(0XFF439036)
|
||||
val Green900 = Color(0XFF225C18)
|
||||
val Green950 = Color(0XFF10340A)
|
||||
|
||||
val Emerald25 = Color(0XFFEDFDF5)
|
||||
val Emerald50 = Color(0XFFF1FDF4)
|
||||
val Emerald100 = Color(0XFFDDFAE3)
|
||||
val Emerald200 = Color(0XFFBEF3CC)
|
||||
val Emerald300 = Color(0XFF9DE6B4)
|
||||
val Emerald400 = Color(0XFF7FD295)
|
||||
val Emerald500 = Color(0XFF69B87D)
|
||||
val Emerald600 = Color(0XFF539566)
|
||||
val Emerald700 = Color(0XFF447756)
|
||||
val Emerald800 = Color(0XFF375F46)
|
||||
val Emerald900 = Color(0XFF2E4F3C)
|
||||
val Emerald950 = Color(0XFF1D3027)
|
||||
|
||||
val Teal25 = Color(0XFFEFFFFD)
|
||||
val Teal50 = Color(0XFFE1FDF9)
|
||||
val Teal100 = Color(0XFFD7FBF6)
|
||||
val Teal200 = Color(0XFFC6F8F2)
|
||||
val Teal300 = Color(0XFFB5F5EE)
|
||||
val Teal400 = Color(0XFFA7F1E9)
|
||||
val Teal500 = Color(0XFF8AE7DD)
|
||||
val Teal600 = Color(0XFF7AE1D5)
|
||||
val Teal700 = Color(0XFF5CC7BA)
|
||||
val Teal800 = Color(0XFF448E86)
|
||||
val Teal900 = Color(0XFF1C6B63)
|
||||
val Teal950 = Color(0XFF0E4841)
|
||||
|
||||
val Blue25 = Color(0XFFF0F9FF)
|
||||
val Blue50 = Color(0XFFE6F5FF)
|
||||
val Blue100 = Color(0XFFDAF0FF)
|
||||
val Blue200 = Color(0XFFCEECFF)
|
||||
val Blue300 = Color(0XFFBAE4FF)
|
||||
val Blue400 = Color(0XFFAADEFF)
|
||||
val Blue500 = Color(0XFFA0D9FF)
|
||||
val Blue600 = Color(0XFF84CFFF)
|
||||
val Blue700 = Color(0XFF53B2EF)
|
||||
val Blue800 = Color(0XFF2F94D5)
|
||||
val Blue900 = Color(0XFF1C70A5)
|
||||
val Blue950 = Color(0XFF004B7B)
|
||||
|
||||
val AFFiNE25 = Color(0XFFE2F4FF)
|
||||
val AFFiNE50 = Color(0XFFCAE9FF)
|
||||
val AFFiNE100 = Color(0XFF8FD1FF)
|
||||
val AFFiNE200 = Color(0XFF79C8FF)
|
||||
val AFFiNE300 = Color(0XFF5EBCFF)
|
||||
val AFFiNE400 = Color(0XFF49B1FA)
|
||||
val AFFiNE500 = Color(0XFF29A2FA)
|
||||
val AFFiNE600 = Color(0XFF1D96EB)
|
||||
val AFFiNE700 = Color(0XFF158ADE)
|
||||
val AFFiNE800 = Color(0XFF035F9F)
|
||||
val AFFiNE900 = Color(0XFF003C67)
|
||||
val AFFiNE950 = Color(0XFF002742)
|
||||
|
||||
val Indigo25 = Color(0XFFF5F7FF)
|
||||
val Indigo50 = Color(0XFFEEF2FF)
|
||||
val Indigo100 = Color(0XFFE0E7FF)
|
||||
val Indigo200 = Color(0XFFC7D2FE)
|
||||
val Indigo300 = Color(0XFFA5B4FC)
|
||||
val Indigo400 = Color(0XFF818CF8)
|
||||
val Indigo500 = Color(0XFF6366F1)
|
||||
val Indigo600 = Color(0XFF4F46E5)
|
||||
val Indigo700 = Color(0XFF4338CA)
|
||||
val Indigo800 = Color(0XFF3730A3)
|
||||
val Indigo900 = Color(0XFF312E81)
|
||||
val Indigo950 = Color(0XFF1E1B4B)
|
||||
|
||||
val Violet25 = Color(0XFFF9F7FF)
|
||||
val Violet50 = Color(0XFFF5F3FF)
|
||||
val Violet100 = Color(0XFFEDE9FE)
|
||||
val Violet200 = Color(0XFFDDD6FE)
|
||||
val Violet300 = Color(0XFFC5B5FD)
|
||||
val Violet400 = Color(0XFFA78BFA)
|
||||
val Violet500 = Color(0XFF8B5CF6)
|
||||
val Violet600 = Color(0XFF7C3AED)
|
||||
val Violet700 = Color(0XFF6E28D9)
|
||||
val Violet800 = Color(0XFF5A21B6)
|
||||
val Violet900 = Color(0XFF4B1E95)
|
||||
val Violet950 = Color(0XFF2E1065)
|
||||
|
||||
val Purple25 = Color(0XFFF0ECFF)
|
||||
val Purple50 = Color(0XFFDED6FF)
|
||||
val Purple100 = Color(0XFFCBBEFF)
|
||||
val Purple200 = Color(0XFFB5A5EF)
|
||||
val Purple300 = Color(0XFFA593F3)
|
||||
val Purple400 = Color(0XFF9681EF)
|
||||
val Purple500 = Color(0XFF846CE9)
|
||||
val Purple600 = Color(0XFF6E52DF)
|
||||
val Purple700 = Color(0XFF5739D1)
|
||||
val Purple800 = Color(0XFF4A2EBC)
|
||||
val Purple900 = Color(0XFF321994)
|
||||
val Purple950 = Color(0XFF25136D)
|
||||
|
||||
val Fuchsia25 = Color(0XFFFEFAFF)
|
||||
val Fuchsia50 = Color(0XFFFCF4FF)
|
||||
val Fuchsia100 = Color(0XFFFAE8FF)
|
||||
val Fuchsia200 = Color(0XFFF5D0FE)
|
||||
val Fuchsia300 = Color(0XFFF0AAFC)
|
||||
val Fuchsia400 = Color(0XFFE879F9)
|
||||
val Fuchsia500 = Color(0XFFD946EF)
|
||||
val Fuchsia600 = Color(0XFFC025D3)
|
||||
val Fuchsia700 = Color(0XFFA21EAF)
|
||||
val Fuchsia800 = Color(0XFF86198F)
|
||||
val Fuchsia900 = Color(0XFF701A75)
|
||||
val Fuchsia950 = Color(0XFF4A044E)
|
||||
|
||||
val Magenta25 = Color(0XFFFFECF6)
|
||||
val Magenta50 = Color(0XFFFFDAED)
|
||||
val Magenta100 = Color(0XFFFFC0E0)
|
||||
val Magenta200 = Color(0XFFFCA2D0)
|
||||
val Magenta300 = Color(0XFFF58EC3)
|
||||
val Magenta400 = Color(0XFFF37FBA)
|
||||
val Magenta500 = Color(0XFFE96CAA)
|
||||
val Magenta600 = Color(0XFFE660A4)
|
||||
val Magenta700 = Color(0XFFCC4187)
|
||||
val Magenta800 = Color(0XFFBA2B72)
|
||||
val Magenta900 = Color(0XFFA91E65)
|
||||
val Magenta950 = Color(0XFF89124F)
|
||||
|
||||
val Rose25 = Color(0XFFFFFAFA)
|
||||
val Rose50 = Color(0XFFFFF1F1)
|
||||
val Rose100 = Color(0XFFFEE4E5)
|
||||
val Rose200 = Color(0XFFFECDD1)
|
||||
val Rose300 = Color(0XFFFDA2AB)
|
||||
val Rose400 = Color(0XFFFB7181)
|
||||
val Rose500 = Color(0XFFF43F48)
|
||||
val Rose600 = Color(0XFFE11E41)
|
||||
val Rose700 = Color(0XFFBE1237)
|
||||
val Rose800 = Color(0XFF9F1235)
|
||||
val Rose900 = Color(0XFF881331)
|
||||
val Rose950 = Color(0XFF4D051A)
|
||||
|
||||
val TransparentWhite25 = Color(0XFFFFFFFF).copy(alpha = 0.03f)
|
||||
val TransparentWhite50 = Color(0XFFFFFFFF).copy(alpha = 0.05f)
|
||||
val TransparentWhite100 = Color(0XFFFFFFFF).copy(alpha = 0.09f)
|
||||
val TransparentWhite200 = Color(0XFFFFFFFF).copy(alpha = 0.13f)
|
||||
val TransparentWhite300 = Color(0XFFFFFFFF).copy(alpha = 0.17f)
|
||||
val TransparentWhite400 = Color(0XFFFFFFFF).copy(alpha = 0.23f)
|
||||
val TransparentWhite500 = Color(0XFFFFFFFF).copy(alpha = 0.56f)
|
||||
val TransparentWhite600 = Color(0XFFFFFFFF).copy(alpha = 0.67f)
|
||||
val TransparentWhite700 = Color(0XFFFFFFFF).copy(alpha = 0.72f)
|
||||
val TransparentWhite800 = Color(0XFFFFFFFF).copy(alpha = 0.82f)
|
||||
val TransparentWhite900 = Color(0XFFFFFFFF).copy(alpha = 0.90f)
|
||||
val TransparentWhite950 = Color(0XFFFFFFFF).copy(alpha = 0.98f)
|
||||
|
||||
val TransparentBlack25 = Color(0XFF000000).copy(alpha = 0.03f)
|
||||
val TransparentBlack50 = Color(0XFF000000).copy(alpha = 0.05f)
|
||||
val TransparentBlack100 = Color(0XFF000000).copy(alpha = 0.07f)
|
||||
val TransparentBlack200 = Color(0XFF000000).copy(alpha = 0.10f)
|
||||
val TransparentBlack300 = Color(0XFF000000).copy(alpha = 0.17f)
|
||||
val TransparentBlack400 = Color(0XFF000000).copy(alpha = 0.22f)
|
||||
val TransparentBlack500 = Color(0XFF000000).copy(alpha = 0.52f)
|
||||
val TransparentBlack600 = Color(0XFF000000).copy(alpha = 0.65f)
|
||||
val TransparentBlack700 = Color(0XFF000000).copy(alpha = 0.70f)
|
||||
val TransparentBlack800 = Color(0XFF000000).copy(alpha = 0.80f)
|
||||
val TransparentBlack900 = Color(0XFF000000).copy(alpha = 0.90f)
|
||||
val TransparentBlack950 = Color(0XFF000000).copy(alpha = 0.95f)
|
||||
|
||||
val TransparentGrey25 = Color(0XFF929292).copy(alpha = 0.03f)
|
||||
val TransparentGrey50 = Color(0XFF929292).copy(alpha = 0.05f)
|
||||
val TransparentGrey100 = Color(0XFF929292).copy(alpha = 0.07f)
|
||||
val TransparentGrey200 = Color(0XFF929292).copy(alpha = 0.10f)
|
||||
val TransparentGrey300 = Color(0XFF929292).copy(alpha = 0.17f)
|
||||
val TransparentGrey400 = Color(0XFF929292).copy(alpha = 0.22f)
|
||||
val TransparentGrey500 = Color(0XFF929292).copy(alpha = 0.52f)
|
||||
val TransparentGrey600 = Color(0XFF929292).copy(alpha = 0.65f)
|
||||
val TransparentGrey700 = Color(0XFF929292).copy(alpha = 0.70f)
|
||||
val TransparentGrey800 = Color(0XFF929292).copy(alpha = 0.80f)
|
||||
val TransparentGrey900 = Color(0XFF929292).copy(alpha = 0.90f)
|
||||
val TransparentGrey950 = Color(0XFF929292).copy(alpha = 0.95f)
|
||||
|
||||
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
package app.affine.pro.theme
|
||||
|
||||
import androidx.compose.foundation.isSystemInDarkTheme
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.CompositionLocalProvider
|
||||
import androidx.compose.runtime.ReadOnlyComposable
|
||||
|
||||
object AFFiNETheme {
|
||||
val colors: AFFiNEColorScheme
|
||||
@ReadOnlyComposable
|
||||
@Composable
|
||||
get() = LocalAFFiNEColors.current
|
||||
|
||||
val typography: AFFiNETypography
|
||||
@ReadOnlyComposable
|
||||
@Composable
|
||||
get() = LocalAFFiNETypography.current
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun AFFiNETheme(
|
||||
mode: ThemeMode = ThemeMode.System,
|
||||
content: @Composable () -> Unit
|
||||
) {
|
||||
val colors = when (mode) {
|
||||
ThemeMode.Light -> affineLightScheme
|
||||
ThemeMode.Dark -> affineDarkScheme
|
||||
ThemeMode.System -> if (isSystemInDarkTheme()) affineDarkScheme else affineLightScheme
|
||||
}
|
||||
|
||||
CompositionLocalProvider(LocalAFFiNEColors provides colors) {
|
||||
MaterialTheme {
|
||||
content()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
enum class ThemeMode(name: String) {
|
||||
Light("light"),
|
||||
Dark("dark"),
|
||||
System("system");
|
||||
|
||||
fun of(name: String) = when (name) {
|
||||
"light" -> Light
|
||||
"dark" -> Dark
|
||||
else -> System
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
package app.affine.pro.theme
|
||||
|
||||
import androidx.compose.runtime.Immutable
|
||||
import androidx.compose.runtime.staticCompositionLocalOf
|
||||
import androidx.compose.ui.text.TextStyle
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.sp
|
||||
|
||||
@Immutable
|
||||
data class AFFiNETypography(
|
||||
val body: TextStyle,
|
||||
val h1: TextStyle,
|
||||
val h2: TextStyle,
|
||||
val h3: TextStyle,
|
||||
val h4: TextStyle,
|
||||
val h5: TextStyle,
|
||||
val h6: TextStyle,
|
||||
)
|
||||
|
||||
val affineTypography = AFFiNETypography(
|
||||
body = TextStyle(
|
||||
fontSize = 16.sp,
|
||||
lineHeight = 21.sp,
|
||||
fontWeight = FontWeight.Normal,
|
||||
),
|
||||
h1 = TextStyle(
|
||||
fontSize = 28.sp,
|
||||
lineHeight = 34.sp,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
),
|
||||
h2 = TextStyle(
|
||||
fontSize = 22.sp,
|
||||
lineHeight = 28.sp,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
),
|
||||
h3 = TextStyle(
|
||||
fontSize = 20.sp,
|
||||
lineHeight = 25.sp,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
),
|
||||
h4 = TextStyle(
|
||||
fontSize = 17.sp,
|
||||
lineHeight = 22.sp,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
),
|
||||
h5 = TextStyle(
|
||||
fontSize = 17.sp,
|
||||
lineHeight = 22.sp,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
),
|
||||
h6 = TextStyle(
|
||||
fontSize = 16.sp,
|
||||
lineHeight = 21.sp,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
),
|
||||
)
|
||||
|
||||
val LocalAFFiNETypography = staticCompositionLocalOf { affineTypography }
|
||||
@@ -0,0 +1,38 @@
|
||||
package app.affine.pro.utils
|
||||
|
||||
import android.content.Context
|
||||
import androidx.datastore.core.DataStore
|
||||
import androidx.datastore.preferences.core.Preferences
|
||||
import androidx.datastore.preferences.core.edit
|
||||
import androidx.datastore.preferences.core.emptyPreferences
|
||||
import androidx.datastore.preferences.core.stringPreferencesKey
|
||||
import androidx.datastore.preferences.preferencesDataStore
|
||||
import androidx.datastore.core.handlers.ReplaceFileCorruptionHandler
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.flow.map
|
||||
|
||||
val Context.dataStore: DataStore<Preferences> by preferencesDataStore(name = "affine")
|
||||
|
||||
internal val authDataStoreCorruptionHandler =
|
||||
ReplaceFileCorruptionHandler<Preferences> { emptyPreferences() }
|
||||
|
||||
val Context.authDataStore: DataStore<Preferences> by preferencesDataStore(
|
||||
name = "auth-sessions",
|
||||
corruptionHandler = authDataStoreCorruptionHandler,
|
||||
)
|
||||
|
||||
suspend fun DataStore<Preferences>.set(key: String, value: String) {
|
||||
edit {
|
||||
it[stringPreferencesKey(key)] = value
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun DataStore<Preferences>.del(key: String) {
|
||||
edit {
|
||||
it.remove(stringPreferencesKey(key))
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun DataStore<Preferences>.get(key: String) = data.map {
|
||||
it[stringPreferencesKey(key)] ?: ""
|
||||
}.first()
|
||||
@@ -0,0 +1,14 @@
|
||||
package app.affine.pro.utils
|
||||
|
||||
import android.content.Context
|
||||
import android.util.TypedValue
|
||||
|
||||
fun Context.dp2px(dp: Int) = TypedValue.applyDimension(
|
||||
TypedValue.COMPLEX_UNIT_DIP,
|
||||
dp.toFloat(),
|
||||
resources.displayMetrics
|
||||
).toInt()
|
||||
|
||||
fun Context.px2dp(px: Int): Int {
|
||||
return (px / resources.displayMetrics.density).toInt()
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
package app.affine.pro.utils
|
||||
|
||||
import com.getcapacitor.Bridge
|
||||
import kotlin.coroutines.resume
|
||||
import kotlin.coroutines.suspendCoroutine
|
||||
|
||||
suspend fun Bridge.getCurrentServerBaseUrl() = eval("window.getCurrentServerBaseUrl()").strict()
|
||||
|
||||
suspend fun Bridge.getCurrentWorkspaceId() = eval("window.getCurrentWorkspaceId()").strict()
|
||||
|
||||
suspend fun Bridge.getCurrentDocId() = eval("window.getCurrentDocId()").strict()
|
||||
|
||||
suspend fun Bridge.getCurrentDocContentInMarkdown() =
|
||||
eval("window.getCurrentDocContentInMarkdown()").strict()
|
||||
|
||||
private suspend fun Bridge.eval(js: String): String {
|
||||
return suspendCoroutine { continuation ->
|
||||
eval(js) { result ->
|
||||
continuation.resume(result)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun String.strict() = let {
|
||||
if (startsWith("\"") && endsWith("\"")) {
|
||||
substring(1, lastIndex)
|
||||
} else {
|
||||
this
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
package app.affine.pro.utils.logger
|
||||
|
||||
import timber.log.Timber
|
||||
|
||||
class AffineDebugTree : Timber.DebugTree() {
|
||||
|
||||
override fun createStackElementTag(element: StackTraceElement): String {
|
||||
return "Affine:${super.createStackElementTag(element)}:${element.lineNumber}"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
package app.affine.pro.utils.logger
|
||||
|
||||
import android.util.Log
|
||||
import com.google.firebase.crashlytics.ktx.crashlytics
|
||||
import com.google.firebase.ktx.Firebase
|
||||
import timber.log.Timber
|
||||
|
||||
class CrashlyticsTree : Timber.Tree() {
|
||||
|
||||
private val crashlytics = Firebase.crashlytics
|
||||
|
||||
override fun log(priority: Int, tag: String?, message: String, t: Throwable?) {
|
||||
|
||||
if (priority < Log.INFO) {
|
||||
return
|
||||
}
|
||||
|
||||
val level = when (priority) {
|
||||
Log.ASSERT -> "[assert]"
|
||||
Log.ERROR -> "[error]"
|
||||
Log.WARN -> "[warn]"
|
||||
else -> "[info]"
|
||||
}
|
||||
|
||||
crashlytics.log(
|
||||
StringBuilder(level)
|
||||
.append(tag?.let { "[$tag]" } ?: "")
|
||||
.append(" ")
|
||||
.append(message)
|
||||
.toString()
|
||||
)
|
||||
|
||||
if (t == null) return
|
||||
crashlytics.recordException(t)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
package app.affine.pro.utils.logger
|
||||
|
||||
import android.content.Context
|
||||
import android.net.Uri
|
||||
import android.util.Log
|
||||
import app.affine.pro.BuildConfig
|
||||
import app.affine.pro.service.CookieStore
|
||||
import com.google.firebase.ktx.Firebase
|
||||
import com.google.firebase.storage.ktx.storage
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.MainScope
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.suspendCancellableCoroutine
|
||||
import kotlinx.coroutines.withContext
|
||||
import okhttp3.HttpUrl
|
||||
import timber.log.Timber
|
||||
import java.io.File
|
||||
import java.io.FileOutputStream
|
||||
import java.io.IOException
|
||||
import java.text.SimpleDateFormat
|
||||
import java.util.Date
|
||||
import java.util.Locale
|
||||
|
||||
class FileTree(context: Context) : Timber.Tree() {
|
||||
|
||||
private val dateFormat = SimpleDateFormat("yyyy-MM-dd", Locale.getDefault())
|
||||
private val logDirectory: File = File(context.filesDir, "logs")
|
||||
private val currentLogFile: File
|
||||
|
||||
init {
|
||||
if (!logDirectory.exists()) {
|
||||
logDirectory.mkdirs()
|
||||
}
|
||||
val today = dateFormat.format(Date())
|
||||
currentLogFile = File(logDirectory, "$today.log")
|
||||
if (!currentLogFile.exists()) {
|
||||
try {
|
||||
currentLogFile.createNewFile()
|
||||
} catch (e: IOException) {
|
||||
Timber.e(e, "Create log file fail.")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun checkAndUploadOldLogs(server: HttpUrl) {
|
||||
val today = dateFormat.format(Date())
|
||||
logDirectory.listFiles()?.forEach { file ->
|
||||
val fileName = file.name
|
||||
if (fileName.endsWith(".log") && !fileName.startsWith(today)) {
|
||||
uploadLogToFirebase(server, file)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun uploadLogToFirebase(server: HttpUrl, file: File) =
|
||||
suspendCancellableCoroutine { continuation ->
|
||||
val user = CookieStore.getCookie(server, CookieStore.AFFINE_USER_ID)
|
||||
?: return@suspendCancellableCoroutine
|
||||
val storageRef = Firebase.storage.reference
|
||||
val logFileRef = storageRef.child("android_log/$user/${file.name}")
|
||||
|
||||
val uploadTask = logFileRef.putFile(Uri.fromFile(file))
|
||||
uploadTask.addOnSuccessListener {
|
||||
if (file.delete()) {
|
||||
if (continuation.isActive) continuation.resume(true) { _, _, _ -> }
|
||||
} else {
|
||||
if (continuation.isActive) continuation.resume(false) { _, _, _ -> }
|
||||
}
|
||||
}.addOnFailureListener { e ->
|
||||
if (continuation.isActive) continuation.resume(false) {}
|
||||
}
|
||||
|
||||
continuation.invokeOnCancellation {
|
||||
if (uploadTask.isInProgress) {
|
||||
uploadTask.cancel()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun log(priority: Int, tag: String?, message: String, t: Throwable?) {
|
||||
if (priority < Log.INFO) {
|
||||
return
|
||||
}
|
||||
|
||||
val level = when (priority) {
|
||||
Log.ASSERT -> "[assert]"
|
||||
Log.ERROR -> "[error]"
|
||||
Log.WARN -> "[warn]"
|
||||
else -> "[info]"
|
||||
}
|
||||
val log = StringBuilder(level)
|
||||
.append(tag?.let { "[$tag]" } ?: "")
|
||||
.append(" ")
|
||||
.append(message)
|
||||
.toString()
|
||||
|
||||
MainScope().launch {
|
||||
try {
|
||||
val timestamp =
|
||||
SimpleDateFormat("yyyy-MM-dd HH:mm:ss.SSS", Locale.getDefault()).format(Date())
|
||||
val logMessage = "[$timestamp] $log\n"
|
||||
withContext(Dispatchers.IO) {
|
||||
FileOutputStream(currentLogFile, true).use {
|
||||
it.write(logMessage.toByteArray())
|
||||
t?.stackTraceToString()?.let { stacktrace ->
|
||||
it.write(stacktrace.toByteArray())
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (e: IOException) {
|
||||
Timber.e(e, "Failed to write to log file")
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
companion object {
|
||||
fun get() = Timber.forest().filterIsInstance<FileTree>().firstOrNull()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:viewportWidth="24"
|
||||
android:viewportHeight="24">
|
||||
<path
|
||||
android:pathData="M11.281,5.491C11.24,5.13 10.935,4.858 10.572,4.857C10.209,4.857 9.903,5.129 9.862,5.49C9.591,7.835 8.89,9.484 7.759,10.616C6.627,11.747 4.978,12.448 2.632,12.719C2.272,12.761 2,13.066 2,13.429C2,13.792 2.273,14.097 2.634,14.138C4.941,14.4 6.625,15.101 7.783,16.238C8.937,17.371 9.653,19.02 9.86,21.349C9.893,21.718 10.202,22 10.572,22C10.943,22 11.251,21.716 11.283,21.347C11.481,19.056 12.197,17.373 13.356,16.213C14.516,15.054 16.199,14.339 18.49,14.14C18.859,14.108 19.142,13.8 19.143,13.429C19.143,13.059 18.861,12.75 18.492,12.717C16.163,12.51 14.514,11.794 13.381,10.64C12.243,9.482 11.542,7.798 11.281,5.491Z"
|
||||
android:fillColor="#7A7A7A"/>
|
||||
<path
|
||||
android:pathData="M18.943,2.247C18.927,2.106 18.808,2 18.667,2C18.526,2 18.407,2.106 18.391,2.246C18.285,3.158 18.013,3.8 17.573,4.239C17.133,4.679 16.491,4.952 15.579,5.057C15.439,5.074 15.333,5.192 15.333,5.334C15.333,5.475 15.439,5.593 15.58,5.609C16.477,5.711 17.132,5.984 17.582,6.426C18.031,6.866 18.309,7.508 18.39,8.413C18.403,8.557 18.523,8.667 18.667,8.667C18.811,8.667 18.931,8.556 18.943,8.413C19.021,7.522 19.299,6.867 19.75,6.416C20.201,5.965 20.855,5.687 21.746,5.61C21.89,5.598 22,5.478 22,5.334C22,5.19 21.89,5.069 21.747,5.057C20.841,4.976 20.2,4.698 19.759,4.249C19.317,3.799 19.044,3.144 18.943,2.247Z"
|
||||
android:fillColor="#7A7A7A"/>
|
||||
</vector>
|
||||
@@ -0,0 +1,10 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:viewportWidth="24"
|
||||
android:viewportHeight="24">
|
||||
<path
|
||||
android:pathData="M17.58,8.97C17.873,9.263 17.873,9.737 17.58,10.03L12.58,15.03C12.288,15.323 11.813,15.323 11.52,15.03L6.52,10.03C6.227,9.737 6.227,9.263 6.52,8.97C6.813,8.677 7.287,8.677 7.58,8.97L12.05,13.439L16.52,8.97C16.813,8.677 17.288,8.677 17.58,8.97Z"
|
||||
android:fillColor="#7A7A7A"
|
||||
android:fillType="evenOdd"/>
|
||||
</vector>
|
||||
@@ -0,0 +1,10 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:viewportWidth="24"
|
||||
android:viewportHeight="24">
|
||||
<path
|
||||
android:pathData="M17.507,10.997C17.202,11.277 16.728,11.257 16.448,10.952L12.75,6.925L12.75,19C12.75,19.414 12.414,19.75 12,19.75C11.586,19.75 11.25,19.414 11.25,19L11.25,6.925L7.552,10.952C7.272,11.257 6.798,11.277 6.493,10.997C6.188,10.717 6.167,10.242 6.448,9.937L11.448,4.493C11.59,4.338 11.79,4.25 12,4.25C12.21,4.25 12.41,4.338 12.552,4.493L17.552,9.937C17.833,10.242 17.812,10.717 17.507,10.997Z"
|
||||
android:fillColor="#7A7A7A"
|
||||
android:fillType="evenOdd"/>
|
||||
</vector>
|
||||
@@ -0,0 +1,10 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:viewportWidth="24"
|
||||
android:viewportHeight="24">
|
||||
<path
|
||||
android:pathData="M20.777,3.723C21.074,4.02 21.074,4.501 20.777,4.798L13.809,11.766C15.092,13.503 15.15,15.9 13.877,17.712L10.996,21.812C10.786,22.11 10.375,22.183 10.076,21.976C7.125,19.933 4.567,17.375 2.524,14.424C2.317,14.125 2.39,13.714 2.688,13.504L6.788,10.623C8.6,9.35 10.997,9.408 12.734,10.691L19.702,3.723C19.999,3.426 20.48,3.426 20.777,3.723ZM10.249,20.231L12.633,16.838C12.642,16.825 12.651,16.811 12.66,16.798C11.637,16.397 10.604,15.715 9.695,14.805C8.785,13.896 8.103,12.863 7.702,11.84C7.689,11.849 7.675,11.858 7.662,11.867L4.269,14.251C5.952,16.532 7.968,18.548 10.249,20.231ZM9.112,11.269C9.425,12.077 9.982,12.943 10.77,13.73C11.557,14.518 12.423,15.075 13.231,15.388C13.42,14.277 13.078,13.105 12.236,12.264C11.395,11.422 10.223,11.08 9.112,11.269Z"
|
||||
android:fillColor="#7A7A7A"
|
||||
android:fillType="evenOdd"/>
|
||||
</vector>
|
||||
@@ -0,0 +1,10 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:viewportWidth="24"
|
||||
android:viewportHeight="24">
|
||||
<path
|
||||
android:pathData="M2.25,9C2.25,6.377 4.377,4.25 7,4.25H17C19.623,4.25 21.75,6.377 21.75,9V12.798C21.75,15.421 19.623,17.548 17,17.548H11.127L7.933,20.547C7.715,20.751 7.396,20.807 7.122,20.688C6.847,20.569 6.67,20.299 6.67,20V17.536C4.2,17.367 2.25,15.31 2.25,12.798V9ZM7,5.75C5.205,5.75 3.75,7.205 3.75,9V12.798C3.75,14.593 5.205,16.048 7,16.048H7.42C7.834,16.048 8.17,16.383 8.17,16.798V18.267L10.317,16.251C10.456,16.12 10.64,16.048 10.83,16.048H17C18.795,16.048 20.25,14.593 20.25,12.798V9C20.25,7.205 18.795,5.75 17,5.75H7Z"
|
||||
android:fillColor="#7A7A7A"
|
||||
android:fillType="evenOdd"/>
|
||||
</vector>
|
||||
@@ -0,0 +1,9 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:viewportWidth="24"
|
||||
android:viewportHeight="24">
|
||||
<path
|
||||
android:pathData="M7,12m-3,0a3,3 0,1 1,6 0a3,3 0,1 1,-6 0"
|
||||
android:fillColor="#7A7A7A"/>
|
||||
</vector>
|
||||
@@ -0,0 +1,10 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:viewportWidth="24"
|
||||
android:viewportHeight="24">
|
||||
<path
|
||||
android:pathData="M7,14.25C8.243,14.25 9.25,13.243 9.25,12C9.25,10.757 8.243,9.75 7,9.75C5.757,9.75 4.75,10.757 4.75,12C4.75,13.243 5.757,14.25 7,14.25ZM7,15C8.657,15 10,13.657 10,12C10,10.343 8.657,9 7,9C5.343,9 4,10.343 4,12C4,13.657 5.343,15 7,15Z"
|
||||
android:fillColor="#7A7A7A"
|
||||
android:fillType="evenOdd"/>
|
||||
</vector>
|
||||
@@ -0,0 +1,9 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:viewportWidth="24"
|
||||
android:viewportHeight="24">
|
||||
<path
|
||||
android:pathData="M6.408,9.245C6.735,8.918 7.265,8.918 7.592,9.245L9.755,11.408C10.082,11.735 10.082,12.265 9.755,12.592L7.592,14.755C7.265,15.082 6.735,15.082 6.408,14.755L4.245,12.592C3.918,12.265 3.918,11.735 4.245,11.408L6.408,9.245Z"
|
||||
android:fillColor="#7A7A7A"/>
|
||||
</vector>
|
||||
@@ -0,0 +1,10 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:viewportWidth="24"
|
||||
android:viewportHeight="24">
|
||||
<path
|
||||
android:pathData="M9.163,12L7,9.837L4.837,12L7,14.163L9.163,12ZM7.592,9.245C7.265,8.918 6.735,8.918 6.408,9.245L4.245,11.408C3.918,11.735 3.918,12.265 4.245,12.592L6.408,14.755C6.735,15.082 7.265,15.082 7.592,14.755L9.755,12.592C10.082,12.265 10.082,11.735 9.755,11.408L7.592,9.245Z"
|
||||
android:fillColor="#7A7A7A"
|
||||
android:fillType="evenOdd"/>
|
||||
</vector>
|
||||
@@ -0,0 +1,10 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:viewportWidth="24"
|
||||
android:viewportHeight="24">
|
||||
<path
|
||||
android:pathData="M10.285,5.75C9.936,5.75 9.615,5.922 9.427,6.2L8.704,7.266C8.233,7.962 7.444,8.375 6.604,8.375H5.778C5.199,8.375 4.75,8.834 4.75,9.375V17.25C4.75,17.791 5.199,18.25 5.778,18.25H18.222C18.801,18.25 19.25,17.791 19.25,17.25V9.375C19.25,8.834 18.801,8.375 18.222,8.375H17.396C16.556,8.375 15.767,7.962 15.296,7.266L14.573,6.2C14.385,5.922 14.064,5.75 13.715,5.75H10.285ZM8.185,5.359C8.656,4.663 9.445,4.25 10.285,4.25H13.715C14.555,4.25 15.344,4.663 15.815,5.359L16.538,6.425C16.726,6.703 17.047,6.875 17.396,6.875H18.222C19.607,6.875 20.75,7.983 20.75,9.375V17.25C20.75,18.642 19.607,19.75 18.222,19.75H5.778C4.393,19.75 3.25,18.642 3.25,17.25V9.375C3.25,7.983 4.393,6.875 5.778,6.875H6.604C6.953,6.875 7.274,6.703 7.462,6.425L8.185,5.359ZM12,11C10.93,11 10.083,11.851 10.083,12.875C10.083,13.899 10.93,14.75 12,14.75C13.07,14.75 13.917,13.899 13.917,12.875C13.917,11.851 13.07,11 12,11ZM8.583,12.875C8.583,11 10.124,9.5 12,9.5C13.876,9.5 15.417,11 15.417,12.875C15.417,14.75 13.876,16.25 12,16.25C10.124,16.25 8.583,14.75 8.583,12.875Z"
|
||||
android:fillColor="#7A7A7A"
|
||||
android:fillType="evenOdd"/>
|
||||
</vector>
|
||||
@@ -0,0 +1,10 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:viewportWidth="24"
|
||||
android:viewportHeight="24">
|
||||
<path
|
||||
android:pathData="M5.47,5.47C5.763,5.177 6.237,5.177 6.53,5.47L12,10.939L17.47,5.47C17.763,5.177 18.237,5.177 18.53,5.47C18.823,5.763 18.823,6.237 18.53,6.53L13.061,12L18.53,17.47C18.823,17.763 18.823,18.237 18.53,18.53C18.237,18.823 17.763,18.823 17.47,18.53L12,13.061L6.53,18.53C6.237,18.823 5.763,18.823 5.47,18.53C5.177,18.237 5.177,17.763 5.47,17.47L10.939,12L5.47,6.53C5.177,6.237 5.177,5.763 5.47,5.47Z"
|
||||
android:fillColor="#7A7A7A"
|
||||
android:fillType="evenOdd"/>
|
||||
</vector>
|
||||
@@ -0,0 +1,10 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:viewportWidth="24"
|
||||
android:viewportHeight="24">
|
||||
<path
|
||||
android:pathData="M6,3.25C4.481,3.25 3.25,4.481 3.25,6V16V18C3.25,19.519 4.481,20.75 6,20.75H18C19.519,20.75 20.75,19.519 20.75,18V14V6C20.75,4.481 19.519,3.25 18,3.25H6ZM4.75,18V16.311L9.116,11.944C9.604,11.456 10.396,11.456 10.884,11.944L13.47,14.53L15.47,16.53C15.763,16.823 16.237,16.823 16.53,16.53C16.823,16.237 16.823,15.763 16.53,15.47L15.061,14L16.116,12.944C16.604,12.456 17.396,12.456 17.884,12.944L19.25,14.311V18C19.25,18.69 18.69,19.25 18,19.25H6C5.31,19.25 4.75,18.69 4.75,18ZM11.944,10.884L14,12.939L15.056,11.884C16.129,10.81 17.871,10.81 18.944,11.884L19.25,12.189V6C19.25,5.31 18.69,4.75 18,4.75H6C5.31,4.75 4.75,5.31 4.75,6V14.189L8.055,10.884C9.129,9.81 10.871,9.81 11.944,10.884ZM14,9C14.552,9 15,8.552 15,8C15,7.448 14.552,7 14,7C13.448,7 13,7.448 13,8C13,8.552 13.448,9 14,9Z"
|
||||
android:fillColor="#7A7A7A"
|
||||
android:fillType="evenOdd"/>
|
||||
</vector>
|
||||
@@ -0,0 +1,9 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="108dp"
|
||||
android:height="108dp"
|
||||
android:viewportWidth="108"
|
||||
android:viewportHeight="108">
|
||||
<path
|
||||
android:pathData="M0,0h108v108h-108z"
|
||||
android:fillColor="#ffffff"/>
|
||||
</vector>
|
||||
@@ -0,0 +1,15 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="108dp"
|
||||
android:height="108dp"
|
||||
android:viewportWidth="108"
|
||||
android:viewportHeight="108">
|
||||
<group android:scaleX="0.75"
|
||||
android:scaleY="0.75"
|
||||
android:translateX="13.5"
|
||||
android:translateY="13.5">
|
||||
<path
|
||||
android:pathData="M49.64,32.93C51.24,30.16 52.44,28.08 53.01,27.11L53.01,27.11C53.44,26.35 54.56,26.36 54.99,27.11C55.2,27.46 55.6,28.15 56,28.87L56,28.87C56.46,29.66 56.92,30.48 57.16,30.87C60.42,36.52 64.34,43.31 68.32,50.21L68.37,50.29C70.21,53.48 72.07,56.7 73.88,59.83L49.64,32.93ZM44.39,42.04C36.94,54.94 27.32,71.61 25.72,74.41C25.44,75.09 25.96,75.95 26.71,75.97C26.85,75.98 27.72,75.98 28.28,75.98C28.48,75.98 28.64,75.98 28.72,75.98C30.18,75.98 31.74,75.98 33.38,75.98L44.39,42.04ZM44.17,75.98C48.26,75.98 52.53,75.98 56.76,75.98C65.51,75.98 74.12,75.98 80.73,75.98L80.8,75.98H80.8C81.02,75.98 81.28,75.98 81.29,75.97C81.35,75.97 81.39,75.96 81.44,75.95L81.44,75.95C81.47,75.94 81.5,75.94 81.54,75.93C82.15,75.75 82.53,75.02 82.28,74.41C82.25,74.38 82.24,74.35 82.23,74.32C82.21,74.27 82.18,74.21 82.08,74.05C81.08,72.32 80.03,70.49 78.92,68.57L44.17,75.98ZM56.71,23.96C54.53,22.37 51.35,23.04 50.02,25.38C48.96,27.24 45.74,32.81 41.85,39.55L41.85,39.55C34.29,52.63 24.21,70.07 22.53,73.1C21.5,75.58 22.94,78.53 25.53,79.25C26.29,79.45 27.08,79.44 27.83,79.43H27.83C28.13,79.42 28.43,79.41 28.72,79.42C35.51,79.43 44.41,79.43 53.42,79.42H53.42H53.42H53.42H53.42C63.28,79.42 73.26,79.42 80.73,79.42C81.14,79.43 81.86,79.42 82.47,79.25C84.35,78.73 85.75,76.94 85.8,74.99C85.82,74.35 85.7,73.7 85.47,73.1C85.26,72.64 85.08,72.34 84.91,72.04C84.84,71.93 84.78,71.82 84.71,71.7L83.26,69.19L71.65,49.08L60.15,29.14L58.7,26.64L57.98,25.38C57.65,24.84 57.22,24.35 56.71,23.96ZM74.49,63.12C73.82,62.54 73.14,61.98 72.43,61.45L48.97,72.8C48.91,72.84 48.85,72.88 48.79,72.92L77.16,65.89L74.49,63.12ZM54.16,68.07L69.73,59.66C68.83,59.14 67.9,58.67 66.93,58.26L54.16,68.07ZM56.52,63.82L64.05,57.28C63.65,57.17 63.24,57.08 62.82,56.99C62.09,56.86 61.36,56.77 60.62,56.72L56.52,63.82ZM52.76,66.04C52.35,66.66 51.91,67.25 51.43,67.82C51.15,68.13 50.86,68.44 50.57,68.73L48.67,58.95L52.76,66.04ZM48.62,54.58C48.29,53.92 48,53.23 47.75,52.53C47.62,52.13 47.5,51.73 47.39,51.33L56.81,54.58H48.62ZM46.8,42.05L68.01,56.47C68.06,56.5 68.11,56.52 68.16,56.54L48.13,35.73L47.69,37.25C47.29,38.84 46.99,40.44 46.8,42.05ZM42.49,74.03C40.68,74.79 38.79,75.39 36.86,75.87L36.55,75.95L44.34,48.91L42.49,74.03ZM48.27,70.75C47.35,71.44 46.39,72.06 45.38,72.62L45.92,53.61C45.97,53.72 46.01,53.84 46.06,53.95L48.27,70.75ZM61.38,54.37L46.61,45.27C46.61,46.29 46.67,47.31 46.8,48.33L61.38,54.37ZM50.33,57.62L53.46,63.05H53.47C53.7,63.46 54.3,63.46 54.54,63.05L57.67,57.62C57.91,57.2 57.61,56.68 57.14,56.68H50.87C50.39,56.68 50.09,57.2 50.33,57.62Z"
|
||||
android:fillColor="#000000"
|
||||
android:fillType="evenOdd"/>
|
||||
</group>
|
||||
</vector>
|
||||
@@ -0,0 +1,10 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:viewportWidth="24"
|
||||
android:viewportHeight="24">
|
||||
<path
|
||||
android:pathData="M4.5,12C4.5,12.956 5.275,13.731 6.231,13.731C7.187,13.731 7.962,12.956 7.962,12C7.962,11.044 7.187,10.269 6.231,10.269C5.275,10.269 4.5,11.044 4.5,12ZM12,13.731C11.044,13.731 10.269,12.956 10.269,12C10.269,11.044 11.044,10.269 12,10.269C12.956,10.269 13.731,11.044 13.731,12C13.731,12.956 12.956,13.731 12,13.731ZM17.769,13.731C16.813,13.731 16.038,12.956 16.038,12C16.038,11.044 16.813,10.269 17.769,10.269C18.725,10.269 19.5,11.044 19.5,12C19.5,12.956 18.725,13.731 17.769,13.731Z"
|
||||
android:fillColor="#7A7A7A"
|
||||
android:fillType="evenOdd"/>
|
||||
</vector>
|
||||
@@ -0,0 +1,9 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:viewportWidth="24"
|
||||
android:viewportHeight="24">
|
||||
<path
|
||||
android:pathData="M20.088,4.143C20.088,4.143 21.892,3.44 21.741,5.148C21.691,5.852 21.24,8.314 20.889,10.976L19.687,18.864C19.687,18.864 19.587,20.02 18.685,20.221C17.783,20.422 16.43,19.518 16.179,19.317C15.979,19.166 12.422,16.905 11.169,15.8C10.818,15.498 10.417,14.895 11.219,14.192L16.48,9.167C17.081,8.565 17.682,7.158 15.177,8.866L8.162,13.639C8.162,13.639 7.36,14.142 5.857,13.689L2.6,12.684C2.6,12.684 1.397,11.931 3.452,11.177C8.463,8.816 14.626,6.405 20.087,4.143H20.088Z"
|
||||
android:fillColor="#7A7A7A"/>
|
||||
</vector>
|
||||
@@ -0,0 +1,12 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<androidx.coordinatorlayout.widget.CoordinatorLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:app="http://schemas.android.com/apk/res-auto"
|
||||
xmlns:tools="http://schemas.android.com/tools"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
tools:context=".MainActivity">
|
||||
|
||||
<WebView
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent" />
|
||||
</androidx.coordinatorlayout.widget.CoordinatorLayout>
|
||||
@@ -0,0 +1,13 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<androidx.coordinatorlayout.widget.CoordinatorLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:tools="http://schemas.android.com/tools"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
tools:context=".MainActivity">
|
||||
|
||||
<!-- Derived from @capacitor/android 8.4.2; keep this override aligned with the Capacitor layout. -->
|
||||
<app.affine.pro.AffineEditorWebView
|
||||
android:id="@+id/webview"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent" />
|
||||
</androidx.coordinatorlayout.widget.CoordinatorLayout>
|
||||
@@ -0,0 +1,6 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<background android:drawable="@drawable/ic_launcher_background"/>
|
||||
<foreground android:drawable="@drawable/ic_launcher_foreground"/>
|
||||
<monochrome android:drawable="@drawable/ic_launcher_foreground"/>
|
||||
</adaptive-icon>
|
||||
@@ -0,0 +1,6 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<background android:drawable="@drawable/ic_launcher_background"/>
|
||||
<foreground android:drawable="@drawable/ic_launcher_foreground"/>
|
||||
<monochrome android:drawable="@drawable/ic_launcher_foreground"/>
|
||||
</adaptive-icon>
|
||||
|
After Width: | Height: | Size: 1.1 KiB |
|
After Width: | Height: | Size: 2.5 KiB |
|
After Width: | Height: | Size: 754 B |
|
After Width: | Height: | Size: 1.5 KiB |
|
After Width: | Height: | Size: 1.6 KiB |
|
After Width: | Height: | Size: 3.6 KiB |
|
After Width: | Height: | Size: 2.6 KiB |
|
After Width: | Height: | Size: 5.8 KiB |
|
After Width: | Height: | Size: 4.1 KiB |
|
After Width: | Height: | Size: 8.8 KiB |
@@ -0,0 +1,6 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<color name="layer.background.primary">#FFFFFF</color>
|
||||
<color name="layer.background.primary.dark">#141414</color>
|
||||
<color name="affine_primary">#1E96EB</color>
|
||||
</resources>
|
||||
@@ -0,0 +1,7 @@
|
||||
<?xml version='1.0' encoding='utf-8'?>
|
||||
<resources>
|
||||
<string name="app_name">AFFiNE</string>
|
||||
<string name="title_activity_main">AFFiNE</string>
|
||||
<string name="package_name">app.affine.pro</string>
|
||||
<string name="custom_url_scheme">app.affine.pro</string>
|
||||
</resources>
|
||||
@@ -0,0 +1,23 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
|
||||
<!-- Base application theme. -->
|
||||
<style name="AppTheme" parent="Theme.AppCompat.Light.DarkActionBar">
|
||||
<!-- Customize your theme here. -->
|
||||
<item name="colorPrimary">@color/colorPrimary</item>
|
||||
<item name="colorPrimaryDark">@color/colorPrimaryDark</item>
|
||||
<item name="colorAccent">@color/colorAccent</item>
|
||||
</style>
|
||||
|
||||
<style name="AppTheme.NoActionBar" parent="Theme.AppCompat.DayNight.NoActionBar">
|
||||
<item name="windowActionBar">false</item>
|
||||
<item name="windowNoTitle">true</item>
|
||||
<item name="android:background">@null</item>
|
||||
</style>
|
||||
|
||||
<style name="AppTheme.NoActionBarLaunch" parent="Theme.SplashScreen">
|
||||
<item name="windowSplashScreenBackground">#FFFFFF</item>
|
||||
<item name="postSplashScreenTheme">@style/AppTheme.NoActionBar</item>
|
||||
</style>
|
||||
|
||||
</resources>
|
||||
@@ -0,0 +1,5 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<paths xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<external-path name="my_images" path="." />
|
||||
<cache-path name="my_cache_images" path="." />
|
||||
</paths>
|
||||
@@ -0,0 +1,18 @@
|
||||
package com.getcapacitor.myapp;
|
||||
|
||||
import static org.junit.Assert.*;
|
||||
|
||||
import org.junit.Test;
|
||||
|
||||
/**
|
||||
* Example local unit test, which will execute on the development machine (host).
|
||||
*
|
||||
* @see <a href="http://d.android.com/tools/testing">Testing documentation</a>
|
||||
*/
|
||||
public class ExampleUnitTest {
|
||||
|
||||
@Test
|
||||
public void addition_isCorrect() throws Exception {
|
||||
assertEquals(4, 2 + 2);
|
||||
}
|
||||
}
|
||||