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 };