diff --git a/packages/frontend/apps/android/App/app/build.gradle b/packages/frontend/apps/android/App/app/build.gradle
index 5f3a4a6a7d..f581c7371c 100644
--- a/packages/frontend/apps/android/App/app/build.gradle
+++ b/packages/frontend/apps/android/App/app/build.gradle
@@ -67,12 +67,15 @@ dependencies {
implementation project(':capacitor-cordova-android-plugins')
implementation project(':service')
implementation libs.kotlinx.coroutines.core
+ implementation libs.kotlinx.coroutines.android
implementation libs.androidx.appcompat
implementation libs.androidx.browser
implementation libs.androidx.coordinatorlayout
implementation libs.androidx.core.splashscreen
implementation libs.androidx.core.ktx
+ implementation libs.androidx.material3
implementation libs.apollo.runtime
+ implementation libs.google.material
implementation libs.jna
testImplementation libs.junit
androidTestImplementation libs.androidx.junit
diff --git a/packages/frontend/apps/android/App/app/src/main/java/app/affine/pro/MainActivity.kt b/packages/frontend/apps/android/App/app/src/main/java/app/affine/pro/MainActivity.kt
index 71567a4b68..12ecb05163 100644
--- a/packages/frontend/apps/android/App/app/src/main/java/app/affine/pro/MainActivity.kt
+++ b/packages/frontend/apps/android/App/app/src/main/java/app/affine/pro/MainActivity.kt
@@ -1,26 +1,80 @@
package app.affine.pro
-import android.os.Build
-import android.os.Bundle
-import androidx.annotation.RequiresApi
+import android.content.res.ColorStateList
+import android.view.Gravity
+import android.view.View
+import android.widget.Toast
+import androidx.coordinatorlayout.widget.CoordinatorLayout
+import androidx.core.content.ContextCompat
+import androidx.core.view.updateMargins
+import androidx.lifecycle.lifecycleScope
+import app.affine.pro.plugin.AIButtonPlugin
+import app.affine.pro.plugin.AffineThemePlugin
+import app.affine.pro.utils.dp
import com.getcapacitor.BridgeActivity
import com.getcapacitor.plugin.CapacitorCookies
import com.getcapacitor.plugin.CapacitorHttp
+import com.google.android.material.floatingactionbutton.FloatingActionButton
+import kotlinx.coroutines.launch
-class MainActivity : BridgeActivity() {
+class MainActivity : BridgeActivity(), AIButtonPlugin.Callback, AffineThemePlugin.Callback,
+ View.OnClickListener {
init {
registerPlugins(
listOf(
+ AffineThemePlugin::class.java,
+ AIButtonPlugin::class.java,
CapacitorHttp::class.java,
CapacitorCookies::class.java,
)
)
}
- @RequiresApi(Build.VERSION_CODES.R)
- override fun onCreate(savedInstanceState: Bundle?) {
- super.onCreate(savedInstanceState)
+ private val fab: FloatingActionButton by lazy {
+ FloatingActionButton(this).apply {
+ visibility = View.GONE
+ layoutParams = CoordinatorLayout.LayoutParams(dp(52), dp(52)).apply {
+ gravity = Gravity.END or Gravity.BOTTOM
+ updateMargins(0, 0, dp(24), dp(86))
+ }
+ customSize = dp(52)
+ setImageResource(R.drawable.ic_ai)
+ setOnClickListener(this@MainActivity)
+ val parent = bridge.webView.parent as CoordinatorLayout
+ parent.addView(this)
+ }
+ }
+
+ 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 onClick(v: View) {
+ Toast.makeText(this, "TODO: Start AI chat~", Toast.LENGTH_SHORT).show()
}
}
diff --git a/packages/frontend/apps/android/App/app/src/main/java/app/affine/pro/plugin/AIButtonPlugin.kt b/packages/frontend/apps/android/App/app/src/main/java/app/affine/pro/plugin/AIButtonPlugin.kt
new file mode 100644
index 0000000000..f3bdd4ebe4
--- /dev/null
+++ b/packages/frontend/apps/android/App/app/src/main/java/app/affine/pro/plugin/AIButtonPlugin.kt
@@ -0,0 +1,27 @@
+package app.affine.pro.plugin
+
+import com.getcapacitor.Plugin
+import com.getcapacitor.PluginCall
+import com.getcapacitor.PluginMethod
+import com.getcapacitor.annotation.CapacitorPlugin
+
+@CapacitorPlugin(name = "AIButton")
+class AIButtonPlugin : Plugin() {
+
+ interface Callback {
+ fun present()
+ fun dismiss()
+ }
+
+ @PluginMethod
+ fun present(call: PluginCall) {
+ (activity as? Callback)?.present()
+ call.resolve()
+ }
+
+ @PluginMethod
+ fun dismiss(call: PluginCall) {
+ (activity as? Callback)?.dismiss()
+ call.resolve()
+ }
+}
\ No newline at end of file
diff --git a/packages/frontend/apps/android/App/app/src/main/java/app/affine/pro/plugin/AffineThemePlugin.kt b/packages/frontend/apps/android/App/app/src/main/java/app/affine/pro/plugin/AffineThemePlugin.kt
new file mode 100644
index 0000000000..2ffe44663c
--- /dev/null
+++ b/packages/frontend/apps/android/App/app/src/main/java/app/affine/pro/plugin/AffineThemePlugin.kt
@@ -0,0 +1,20 @@
+package app.affine.pro.plugin
+
+import com.getcapacitor.Plugin
+import com.getcapacitor.PluginCall
+import com.getcapacitor.PluginMethod
+import com.getcapacitor.annotation.CapacitorPlugin
+
+@CapacitorPlugin(name = "AffineTheme")
+class AffineThemePlugin : Plugin() {
+
+ interface Callback {
+ fun onThemeChanged(darkMode: Boolean)
+ }
+
+ @PluginMethod
+ fun onThemeChanged(call: PluginCall) {
+ (bridge.activity as? Callback)?.onThemeChanged(call.data.optBoolean("darkMode"))
+ call.resolve()
+ }
+}
\ No newline at end of file
diff --git a/packages/frontend/apps/android/App/app/src/main/java/app/affine/pro/utils/DensityUtil.kt b/packages/frontend/apps/android/App/app/src/main/java/app/affine/pro/utils/DensityUtil.kt
new file mode 100644
index 0000000000..897567cec0
--- /dev/null
+++ b/packages/frontend/apps/android/App/app/src/main/java/app/affine/pro/utils/DensityUtil.kt
@@ -0,0 +1,10 @@
+package app.affine.pro.utils
+
+import android.content.Context
+import android.util.TypedValue
+
+fun Context.dp(dp: Int) = TypedValue.applyDimension(
+ TypedValue.COMPLEX_UNIT_DIP,
+ dp.toFloat(),
+ resources.displayMetrics
+).toInt()
\ No newline at end of file
diff --git a/packages/frontend/apps/android/App/app/src/main/res/drawable/ic_ai.xml b/packages/frontend/apps/android/App/app/src/main/res/drawable/ic_ai.xml
new file mode 100644
index 0000000000..4e8e57e991
--- /dev/null
+++ b/packages/frontend/apps/android/App/app/src/main/res/drawable/ic_ai.xml
@@ -0,0 +1,12 @@
+
+
+
+
diff --git a/packages/frontend/apps/android/App/app/src/main/res/values/colors.xml b/packages/frontend/apps/android/App/app/src/main/res/values/colors.xml
new file mode 100644
index 0000000000..994b81e591
--- /dev/null
+++ b/packages/frontend/apps/android/App/app/src/main/res/values/colors.xml
@@ -0,0 +1,5 @@
+
+
+ #FFFFFF
+ #141414
+
\ No newline at end of file
diff --git a/packages/frontend/apps/android/App/gradle/libs.versions.toml b/packages/frontend/apps/android/App/gradle/libs.versions.toml
index fb51991720..cab990b0b7 100644
--- a/packages/frontend/apps/android/App/gradle/libs.versions.toml
+++ b/packages/frontend/apps/android/App/gradle/libs.versions.toml
@@ -1,13 +1,15 @@
[versions]
-androidxEspressoCoreVersion = "3.6.1"
-androidxJunitVersion = "1.2.1"
+androidxEspressoCore = "3.6.1"
+androidxJunit = "1.2.1"
browser = "1.8.0"
coreKtx = "1.15.0"
-coreSplashScreenVersion = "1.0.1"
+material = "1.12.0"
+material3 = "1.3.1"
+coreSplashScreen = "1.0.1"
jna = "5.16.0"
junitVersion = "4.13.2"
kotlin = "2.1.10"
-kotlinxCoroutinesCore = "1.10.1"
+kotlinxCoroutines = "1.10.1"
rustAndroid = "0.9.6"
appcompat = "1.7.0"
coordinatorLayout = "1.2.0"
@@ -20,12 +22,15 @@ androidx-appcompat = { module = "androidx.appcompat:appcompat", version.ref = "a
androidx-browser = { module = "androidx.browser:browser", version.ref = "browser" }
androidx-coordinatorlayout = { module = "androidx.coordinatorlayout:coordinatorlayout", version.ref = "coordinatorLayout" }
androidx-core-ktx = { module = "androidx.core:core-ktx", version.ref = "coreKtx" }
-androidx-core-splashscreen = { module = "androidx.core:core-splashscreen", version.ref = "coreSplashScreenVersion" }
-androidx-espresso-core = { module = "androidx.test.espresso:espresso-core", version.ref = "androidxEspressoCoreVersion" }
-androidx-junit = { module = "androidx.test.ext:junit", version.ref = "androidxJunitVersion" }
-kotlinx-coroutines-core = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-core", version.ref = "kotlinxCoroutinesCore" }
+androidx-core-splashscreen = { module = "androidx.core:core-splashscreen", version.ref = "coreSplashScreen" }
+androidx-espresso-core = { module = "androidx.test.espresso:espresso-core", version.ref = "androidxEspressoCore" }
+androidx-junit = { module = "androidx.test.ext:junit", version.ref = "androidxJunit" }
+androidx-material3 = { module = "androidx.compose.material3:material3", version.ref = "material3" }
+kotlinx-coroutines-core = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-core", version.ref = "kotlinxCoroutines" }
+kotlinx-coroutines-android = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-android", version.ref = "kotlinxCoroutines"}
google-services = { module = "com.google.gms:google-services", version.ref = "googleServices" }
+google-material = { module = "com.google.android.material:material", version.ref = "material" }
android-gradlePlugin = { module = "com.android.tools.build:gradle", version.ref = "androidGradlePlugin" }
jna = { module = "net.java.dev.jna:jna", version.ref = "jna" }
junit = { module = "junit:junit", version.ref = "junitVersion" }
diff --git a/packages/frontend/apps/android/src/app.tsx b/packages/frontend/apps/android/src/app.tsx
index d106cfc2ef..49cad24e96 100644
--- a/packages/frontend/apps/android/src/app.tsx
+++ b/packages/frontend/apps/android/src/app.tsx
@@ -4,7 +4,14 @@ import { configureMobileModules } from '@affine/core/mobile/modules';
import { VirtualKeyboardProvider } from '@affine/core/mobile/modules/virtual-keyboard';
import { router } from '@affine/core/mobile/router';
import { configureCommonModules } from '@affine/core/modules';
-import { AuthService, DefaultServerService } from '@affine/core/modules/cloud';
+import { AIButtonProvider } from '@affine/core/modules/ai-button';
+import {
+ AuthService,
+ DefaultServerService,
+ ServersService,
+} from '@affine/core/modules/cloud';
+import { DocsService } from '@affine/core/modules/doc';
+import { GlobalContextService } from '@affine/core/modules/global-context';
import { I18nProvider } from '@affine/core/modules/i18n';
import { LifecycleService } from '@affine/core/modules/lifecycle';
import {
@@ -14,8 +21,20 @@ import {
import { PopupWindowProvider } from '@affine/core/modules/url';
import { ClientSchemeProvider } from '@affine/core/modules/url/providers/client-schema';
import { configureBrowserWorkbenchModule } from '@affine/core/modules/workbench';
+import { WorkspacesService } from '@affine/core/modules/workspace';
import { configureBrowserWorkspaceFlavours } from '@affine/core/modules/workspace-engine';
+import { I18n } from '@affine/i18n';
import { StoreManagerClient } from '@affine/nbstore/worker/client';
+import {
+ defaultBlockMarkdownAdapterMatchers,
+ docLinkBaseURLMiddleware,
+ InlineDeltaToMarkdownAdapterExtensions,
+ MarkdownAdapter,
+ MarkdownInlineToDeltaAdapterExtensions,
+ titleMiddleware,
+} from '@blocksuite/affine/blocks';
+import { Container } from '@blocksuite/affine/global/di';
+import { Transformer } from '@blocksuite/affine/store';
import { App as CapacitorApp } from '@capacitor/app';
import { Keyboard } from '@capacitor/keyboard';
import { StatusBar, Style } from '@capacitor/status-bar';
@@ -27,6 +46,9 @@ import { useTheme } from 'next-themes';
import { Suspense, useEffect } from 'react';
import { RouterProvider } from 'react-router-dom';
+import { AffineTheme } from './plugins/affine-theme';
+import { AIButton } from './plugins/ai-button';
+
const storeManagerClient = new StoreManagerClient(
new OpClient(
new Worker(
@@ -88,6 +110,102 @@ framework.impl(VirtualKeyboardProvider, {
},
});
+framework.impl(AIButtonProvider, {
+ presentAIButton: () => {
+ return AIButton.present();
+ },
+ dismissAIButton: () => {
+ return AIButton.dismiss();
+ },
+});
+
+// ------ some apis for native ------
+(window as any).getCurrentServerBaseUrl = () => {
+ const globalContextService = frameworkProvider.get(GlobalContextService);
+ const currentServerId = globalContextService.globalContext.serverId.get();
+ const serversService = frameworkProvider.get(ServersService);
+ const defaultServerService = frameworkProvider.get(DefaultServerService);
+ const currentServer =
+ (currentServerId ? serversService.server$(currentServerId).value : null) ??
+ defaultServerService.server;
+ return currentServer.baseUrl;
+};
+(window as any).getCurrentI18nLocale = () => {
+ return I18n.language;
+};
+(window as any).getCurrentWorkspaceId = () => {
+ const globalContextService = frameworkProvider.get(GlobalContextService);
+ return globalContextService.globalContext.workspaceId.get();
+};
+(window as any).getCurrentDocId = () => {
+ const globalContextService = frameworkProvider.get(GlobalContextService);
+ return globalContextService.globalContext.docId.get();
+};
+(window as any).getCurrentDocContentInMarkdown = async () => {
+ const globalContextService = frameworkProvider.get(GlobalContextService);
+ const currentWorkspaceId =
+ globalContextService.globalContext.workspaceId.get();
+ const currentDocId = globalContextService.globalContext.docId.get();
+ const workspacesService = frameworkProvider.get(WorkspacesService);
+ const workspaceRef = currentWorkspaceId
+ ? workspacesService.openByWorkspaceId(currentWorkspaceId)
+ : null;
+ if (!workspaceRef) {
+ return;
+ }
+ const { workspace, dispose: disposeWorkspace } = workspaceRef;
+
+ const docsService = workspace.scope.get(DocsService);
+ const docRef = currentDocId ? docsService.open(currentDocId) : null;
+ if (!docRef) {
+ return;
+ }
+ const { doc, release: disposeDoc } = docRef;
+
+ try {
+ const blockSuiteDoc = doc.blockSuiteDoc;
+
+ const transformer = new Transformer({
+ schema: blockSuiteDoc.workspace.schema,
+ blobCRUD: blockSuiteDoc.workspace.blobSync,
+ docCRUD: {
+ create: (id: string) => blockSuiteDoc.workspace.createDoc({ id }),
+ get: (id: string) => blockSuiteDoc.workspace.getDoc(id),
+ delete: (id: string) => blockSuiteDoc.workspace.removeDoc(id),
+ },
+ middlewares: [
+ docLinkBaseURLMiddleware(blockSuiteDoc.workspace.id),
+ titleMiddleware(blockSuiteDoc.workspace.meta.docMetas),
+ ],
+ });
+ const snapshot = transformer.docToSnapshot(blockSuiteDoc);
+
+ const container = new Container();
+ [
+ ...MarkdownInlineToDeltaAdapterExtensions,
+ ...defaultBlockMarkdownAdapterMatchers,
+ ...InlineDeltaToMarkdownAdapterExtensions,
+ ].forEach(ext => {
+ ext.setup(container);
+ });
+ const provider = container.provider();
+
+ const adapter = new MarkdownAdapter(transformer, provider);
+ if (!snapshot) {
+ return;
+ }
+
+ const markdownResult = await adapter.fromDocSnapshot({
+ snapshot,
+ assets: transformer.assetsManager,
+ });
+ return markdownResult.file;
+ } finally {
+ disposeDoc();
+ disposeWorkspace();
+ }
+};
+
// setup application lifecycle events, and emit application start event
window.addEventListener('focus', () => {
frameworkProvider.get(LifecycleService).applicationFocus();
@@ -130,7 +248,7 @@ CapacitorApp.addListener('appUrlOpen', ({ url }) => {
console.error(e);
});
-const EdgeToEdgeCompatibilityProvider = () => {
+const ThemeProvider = () => {
const { resolvedTheme } = useTheme();
useEffect(() => {
@@ -145,8 +263,10 @@ const EdgeToEdgeCompatibilityProvider = () => {
EdgeToEdge.setBackgroundColor({
color: resolvedTheme === 'dark' ? '#000000' : '#F5F5F5',
}).catch(console.error);
+ AffineTheme.onThemeChanged({
+ darkMode: resolvedTheme === 'dark',
+ }).catch(console.error);
}, [resolvedTheme]);
-
return null;
};
@@ -156,7 +276,7 @@ export function App() {
-
+
}
router={router}
diff --git a/packages/frontend/apps/android/src/plugins/affine-theme/definitions.ts b/packages/frontend/apps/android/src/plugins/affine-theme/definitions.ts
new file mode 100644
index 0000000000..62858a91b8
--- /dev/null
+++ b/packages/frontend/apps/android/src/plugins/affine-theme/definitions.ts
@@ -0,0 +1,3 @@
+export interface AffineThemePlugin {
+ onThemeChanged(options: { darkMode: boolean }): Promise;
+}
diff --git a/packages/frontend/apps/android/src/plugins/affine-theme/index.ts b/packages/frontend/apps/android/src/plugins/affine-theme/index.ts
new file mode 100644
index 0000000000..76565a531c
--- /dev/null
+++ b/packages/frontend/apps/android/src/plugins/affine-theme/index.ts
@@ -0,0 +1,8 @@
+import { registerPlugin } from '@capacitor/core';
+
+import type { AffineThemePlugin } from './definitions';
+
+const AffineTheme = registerPlugin('AffineTheme');
+
+export * from './definitions';
+export { AffineTheme };
diff --git a/packages/frontend/apps/android/src/plugins/ai-button/definitions.ts b/packages/frontend/apps/android/src/plugins/ai-button/definitions.ts
new file mode 100644
index 0000000000..bb1e35fe44
--- /dev/null
+++ b/packages/frontend/apps/android/src/plugins/ai-button/definitions.ts
@@ -0,0 +1,4 @@
+export interface AIButtonPlugin {
+ present(): Promise;
+ dismiss(): Promise;
+}
diff --git a/packages/frontend/apps/android/src/plugins/ai-button/index.ts b/packages/frontend/apps/android/src/plugins/ai-button/index.ts
new file mode 100644
index 0000000000..7e86afd947
--- /dev/null
+++ b/packages/frontend/apps/android/src/plugins/ai-button/index.ts
@@ -0,0 +1,8 @@
+import { registerPlugin } from '@capacitor/core';
+
+import type { AIButtonPlugin } from './definitions';
+
+const AIButton = registerPlugin('AIButton');
+
+export * from './definitions';
+export { AIButton };
diff --git a/packages/frontend/core/src/mobile/pages/workspace/detail/mobile-detail-page.tsx b/packages/frontend/core/src/mobile/pages/workspace/detail/mobile-detail-page.tsx
index 5eb4bece69..4b35ef0032 100644
--- a/packages/frontend/core/src/mobile/pages/workspace/detail/mobile-detail-page.tsx
+++ b/packages/frontend/core/src/mobile/pages/workspace/detail/mobile-detail-page.tsx
@@ -87,7 +87,7 @@ const DetailPageImpl = () => {
const enableEdgelessEditing =
featureFlagService.flags.enable_mobile_edgeless_editing.value;
const enableAIButton = useLiveData(
- featureFlagService.flags.enable_ios_ai_button.$
+ featureFlagService.flags.enable_mobile_ai_button.$
);
// TODO(@eyhn): remove jotai here
diff --git a/packages/frontend/core/src/modules/feature-flag/constant.ts b/packages/frontend/core/src/modules/feature-flag/constant.ts
index d0af7170b5..6bf65266cb 100644
--- a/packages/frontend/core/src/modules/feature-flag/constant.ts
+++ b/packages/frontend/core/src/modules/feature-flag/constant.ts
@@ -251,11 +251,11 @@ export const AFFINE_FLAGS = {
configurable: isCanaryBuild,
defaultState: false,
},
- enable_ios_ai_button: {
+ enable_mobile_ai_button: {
category: 'affine',
displayName: 'Enable AI Button',
- description: 'Enable AI Button on iOS',
- configurable: BUILD_CONFIG.isIOS,
+ description: 'Enable AI Button on mobile',
+ configurable: BUILD_CONFIG.isMobileEdition,
defaultState: false,
},
} satisfies { [key in string]: FlagInfo };