diff --git a/CMakeLists.txt b/CMakeLists.txt index 1f436c90cc..e0da6c7a1d 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -12,6 +12,17 @@ message(STATUS "Using CMake version: ${CMAKE_VERSION}") # Select the Qt version we want to use # Qt5 is the default, set QT_MAJOR_VERSION to 6 to look for Qt6 option(BUILD_WITH_QT6 "Build against Qt 6" OFF) +option(KRITA_EINK_SLIM "Build the light, monochrome E Ink painting configuration" OFF) +option(KRITA_XIAOMI_EINK "Enable Xiaomi Mi Reader's optional EPD adapter" OFF) +if(KRITA_EINK_SLIM) + set(BUILD_TESTING OFF CACHE BOOL "Tests are excluded from the E Ink build" FORCE) + set(ENABLE_UPDATERS OFF CACHE BOOL "Updaters are excluded from the E Ink build" FORCE) + set(FOUNDATION_BUILD OFF CACHE BOOL "Release extras are excluded from the E Ink build" FORCE) + add_compile_definitions(KRITA_EINK_SLIM=1) +endif() +if(KRITA_XIAOMI_EINK) + add_compile_definitions(KRITA_XIAOMI_EINK=1) +endif() if (BUILD_WITH_QT6) set(MIN_QT_VERSION 6.0.0) diff --git a/libs/ui/KisApplication.cpp b/libs/ui/KisApplication.cpp index a3bcdb2846..9b7eb6d33f 100644 --- a/libs/ui/KisApplication.cpp +++ b/libs/ui/KisApplication.cpp @@ -549,7 +549,11 @@ bool KisApplication::start(const KisApplicationArguments &args) KConfigGroup group(KSharedConfig::openConfig(), "theme"); #ifndef Q_OS_HAIKU Digikam::ThemeManager themeManager; +#ifdef KRITA_EINK_SLIM + themeManager.setCurrentTheme("Krita light"); +#else themeManager.setCurrentTheme(group.readEntry("Theme", "Krita dark")); +#endif #endif ResetStarting resetStarting(d->splashScreen, args.filenames().count()); // remove the splash when done diff --git a/libs/ui/KisMainWindow.cpp b/libs/ui/KisMainWindow.cpp index d3a1e0afe8..a926a254f9 100644 --- a/libs/ui/KisMainWindow.cpp +++ b/libs/ui/KisMainWindow.cpp @@ -377,7 +377,11 @@ KisMainWindow::KisMainWindow(QUuid uuid) d->viewManager = new KisViewManager(this, actionCollection()); KConfigGroup group( KSharedConfig::openConfig(), "theme"); #ifndef Q_OS_HAIKU +#ifdef KRITA_EINK_SLIM + d->themeManager = new Digikam::ThemeManager("Krita light", this); +#else d->themeManager = new Digikam::ThemeManager(group.readEntry("Theme", "Krita dark"), this); +#endif #endif d->windowStateConfig = KSharedConfig::openConfig()->group("MainWindow"); @@ -2937,7 +2941,11 @@ void KisMainWindow::configChanged() KConfigGroup group( KSharedConfig::openConfig(), "theme"); #ifndef Q_OS_HAIKU +#ifdef KRITA_EINK_SLIM + d->themeManager->setCurrentTheme("Krita light"); +#else d->themeManager->setCurrentTheme(group.readEntry("Theme", "Krita dark")); +#endif #endif d->actionManager()->updateGUI(); diff --git a/libs/ui/thememanager.cpp b/libs/ui/thememanager.cpp index 81ca21e9c5..99e1a2a94d 100644 --- a/libs/ui/thememanager.cpp +++ b/libs/ui/thememanager.cpp @@ -109,7 +109,11 @@ QString ThemeManager::currentThemeName() const } if (themeName.isEmpty()) { //qDebug() << "\tfallback"; +#ifdef KRITA_EINK_SLIM + themeName = "Krita light"; +#else themeName = "Krita dark"; +#endif } //qDebug() << "\tresult" << themeName; return themeName; diff --git a/packaging/android/apk/AndroidManifest.xml b/packaging/android/apk/AndroidManifest.xml index 553aff4c3d..e092710933 100644 --- a/packaging/android/apk/AndroidManifest.xml +++ b/packaging/android/apk/AndroidManifest.xml @@ -1,5 +1,5 @@ - + diff --git a/packaging/android/apk/build.gradle b/packaging/android/apk/build.gradle index be3b33441a..bd9ad0b8d7 100644 --- a/packaging/android/apk/build.gradle +++ b/packaging/android/apk/build.gradle @@ -35,6 +35,7 @@ ext { // tldr; at one time, either qt5AndroidDir or qtResPath is valid and points // to the same files qtResPath = System.getenv('KRITA_BUILD_APPBUNDLE') ? 'extra-bundle-deps/android/java' : "" + einkProfile = (System.getenv('KRITA_XIAOMI_EINK') ?: '0') in ['1', 'ON', 'true', 'TRUE'] } tasks.register('configure') { @@ -141,7 +142,7 @@ android { * `package` attribute in the manifest, because androiddeployqt from Qt 5.15.x * does not know how to fetch the package name from the Manifest */ - namespace = "org.krita" + namespace = "rip.crit.ms.org.krita" ndkPath ndkpath @@ -153,8 +154,10 @@ android { sourceSets { main { + def uNoteSource = System.getenv('UNOTE_SOURCE_DIR') + def uNotePlusSource = System.getenv('UNOTE_PLUS_SOURCE_DIR') manifest.srcFile 'AndroidManifest.xml' - java.srcDirs = [qt5AndroidDir + '/src', qtResPath + '/src', 'src', 'java'] + java.srcDirs = [qt5AndroidDir + '/src', qtResPath + '/src', 'src', 'java'] + (uNoteSource ? [uNoteSource] : []) + (uNotePlusSource ? [uNotePlusSource] : []) aidl.srcDirs = [qt5AndroidDir + '/src', qtResPath + '/src', 'src', 'aidl'] res.srcDirs = [qt5AndroidDir + '/res', qtResPath + '/res', 'res'] resources.srcDirs = ['src'] @@ -170,8 +173,24 @@ android { } } + signingConfigs { + release { + def releaseStore = System.getenv('MISSTRING_RELEASE_STORE_FILE') + if (releaseStore != null) { + storeFile file(releaseStore) + storePassword System.getenv('MISSTRING_RELEASE_STORE_PASSWORD') + keyAlias System.getenv('MISSTRING_RELEASE_KEY_ALIAS') + keyPassword System.getenv('MISSTRING_RELEASE_KEY_PASSWORD') + } + } + } + buildTypes { release { + if (System.getenv('MISSTRING_RELEASE_STORE_FILE') != null) { + signingConfig signingConfigs.release + } minifyEnabled true proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro' @@ -201,6 +220,10 @@ android { def versionRelease = 0 defaultConfig { + applicationId "rip.crit.ms.org.krita" + if (einkProfile) { + ndk { abiFilters 'arm64-v8a' } + } compileSdk androidCompileSdkVersion.toInteger() // even though buildToolsVersion is deprecated, @@ -208,7 +231,8 @@ android { // AGP will use the default one buildToolsVersion "36.0.0" - ndkVersion "27.3.13750724" + // Keep AGP's declared NDK aligned with the NDK selected by ENV.sh or CI. + ndkVersion(System.getenv('KRITA_ANDROID_NDK_VERSION') ?: "28.2.13676358") targetSdkVersion 36 minSdkVersion 24 diff --git a/packaging/android/apk/proguard-rules.pro b/packaging/android/apk/proguard-rules.pro index 91e3b04949..c671c1f36c 100644 --- a/packaging/android/apk/proguard-rules.pro +++ b/packaging/android/apk/proguard-rules.pro @@ -8,3 +8,5 @@ -keep class org.qtproject.qt5.android.** { *; } -keep class org.krita.android.** { *; } -keep class org.libsdl.app.** { *; } +-keep class com.moan.fakerkepdlib.EpdUtil { *; } +-keepclasseswithmembernames class * { native ; } diff --git a/packaging/android/apk/src/org/krita/android/MainActivity.java b/packaging/android/apk/src/org/krita/android/MainActivity.java index 093be86d51..611d322258 100644 --- a/packaging/android/apk/src/org/krita/android/MainActivity.java +++ b/packaging/android/apk/src/org/krita/android/MainActivity.java @@ -30,8 +30,10 @@ import android.widget.Toast; import androidx.annotation.RequiresApi; import java.util.List; +import java.util.Optional; -import org.krita.R; +import rip.crit.ms.org.krita.R; +import rip.crit.ms.unote.UNoteEinkController; import org.libsdl.app.SDLAudioManager; import org.qtproject.qt5.android.QtNative; import org.qtproject.qt5.android.bindings.QtActivity; @@ -48,6 +50,8 @@ public class MainActivity extends QtActivity { private boolean serviceStarted = false; private boolean inFullScreen = false; private DonationDialog mDonationDialog = null; + private XiaomiEinkController einkController = null; + private UNoteEinkController uNoteEinkController = null; @Override @SuppressLint("MissingSuperCall") @@ -71,6 +75,22 @@ public class MainActivity extends QtActivity { Log.i(TAG, "LibsLoaded"); haveLibsLoaded = true; + // The Xiaomi EBC overlay is a private, panel-global resource. Do not acquire it + // automatically. A stale or competing overlay can make the whole display appear + // blank until the system compositor recovers. The adapter remains an explicit + // experimental opt-in while geometry and waveform calibration are device-verified. + final boolean xiaomiOverlayEnabled = getSharedPreferences("krita_eink", MODE_PRIVATE) + .getBoolean("xiaomi_overlay_experimental", false); + if (xiaomiOverlayEnabled) { + try { + einkController = new XiaomiEinkController(getWindow()); + Log.i(TAG, "Xiaomi E Ink overlay enabled by user preference"); + } catch (LinkageError | RuntimeException unavailable) { + Log.i(TAG, "Xiaomi E Ink overlay unavailable", unavailable); + } + } + + if (einkController == null && xiaomiOverlayEnabled) { + Optional controller = UNoteEinkController.create( + this, getWindow().getDecorView(), null); + if (controller.isPresent()) { + uNoteEinkController = controller.get(); + Log.i(TAG, "uNote E Ink provider enabled"); + } + } + DonationHelper.getInstance(); DonationProduct.initAllProducts(this); } @@ -165,9 +185,30 @@ public class MainActivity extends QtActivity { Log.i(TAG, "[onDestroy]"); startServiceGeneric(DocumentSaverService.KILL_PROCESS); + if (einkController != null) { + einkController.close(); + einkController = null; + } + if (uNoteEinkController != null) { + uNoteEinkController.close(); + uNoteEinkController = null; + } super.onDestroy(); } + @Override + public boolean dispatchTouchEvent(MotionEvent event) { + if (einkController != null && (event.isFromSource(InputDevice.SOURCE_STYLUS) + || event.getToolType(0) == MotionEvent.TOOL_TYPE_STYLUS)) { + einkController.onMotionEvent(event); + } + if (uNoteEinkController != null) { + uNoteEinkController.onMotionEvent(event); + } + // Qt receives the original event, including its untouched pressure and tilt axes. + return super.dispatchTouchEvent(event); + } + @Override public boolean onKeyUp(final int keyCode, final KeyEvent event) { if (keyCode == KeyEvent.KEYCODE_BACK) { diff --git a/plugins/CMakeLists.txt b/plugins/CMakeLists.txt index 44d0867fb0..17726b2e35 100644 --- a/plugins/CMakeLists.txt +++ b/plugins/CMakeLists.txt @@ -1,17 +1,24 @@ -add_subdirectory( assistants ) add_subdirectory( color ) add_subdirectory( dockers ) -add_subdirectory( extensions ) -add_subdirectory( filters ) add_subdirectory( flake ) -add_subdirectory( generators ) +add_subdirectory( filters ) add_subdirectory( impex ) -add_subdirectory( metadata ) add_subdirectory( paintops ) add_subdirectory( tools ) -add_subdirectory( qt ) -if ((HAVE_PYQT5 OR HAVE_PYQT6) AND HAVE_SIP AND HAVE_PYTHONLIBS) +if (NOT KRITA_EINK_SLIM) + add_subdirectory( assistants ) + add_subdirectory( extensions ) + add_subdirectory( generators ) + add_subdirectory( metadata ) + add_subdirectory( qt ) +endif() + +if(KRITA_EINK_SLIM) + add_subdirectory(extensions/einksync) +endif() + +if (NOT KRITA_EINK_SLIM AND (HAVE_PYQT5 OR HAVE_PYQT6) AND HAVE_SIP AND HAVE_PYTHONLIBS) add_subdirectory( python ) endif() diff --git a/plugins/dockers/CMakeLists.txt b/plugins/dockers/CMakeLists.txt index f71b5b1042..de9ce3930d 100644 --- a/plugins/dockers/CMakeLists.txt +++ b/plugins/dockers/CMakeLists.txt @@ -1,34 +1,40 @@ add_subdirectory(layerdocker) -if(HAVE_OPENEXR AND NOT ANDROID) - add_subdirectory(smallcolorselector) -endif() add_subdirectory(specificcolorselector) -add_subdirectory(digitalmixer) -add_subdirectory(advancedcolorselector) add_subdirectory(presetdocker) -add_subdirectory(historydocker) -add_subdirectory(channeldocker) -add_subdirectory(artisticcolorselector) -add_subdirectory(tasksetdocker) -add_subdirectory(compositiondocker) -add_subdirectory(patterndocker) -add_subdirectory(griddocker) -add_subdirectory(arrangedocker) -if(HAVE_OCIO AND NOT ANDROID AND NOT HAIKU) - add_subdirectory(lut) -endif() -add_subdirectory(overview) -add_subdirectory(palettedocker) -add_subdirectory(animation) -add_subdirectory(presethistory) -add_subdirectory(svgcollectiondocker) -add_subdirectory(histogram) -add_subdirectory(gamutmask) -add_subdirectory(recorder) -add_subdirectory(touchdocker) -add_subdirectory(logdocker) -add_subdirectory(snapshotdocker) -add_subdirectory(storyboarddocker) -add_subdirectory(widegamutcolorselector) add_subdirectory(brushhud) -add_subdirectory(textproperties) +add_subdirectory(touchdocker) +if(KRITA_EINK_SLIM) + add_subdirectory(einkmonochromeselector) +endif() + +if(NOT KRITA_EINK_SLIM) + if(HAVE_OPENEXR AND NOT ANDROID) + add_subdirectory(smallcolorselector) + endif() + add_subdirectory(digitalmixer) + add_subdirectory(advancedcolorselector) + add_subdirectory(historydocker) + add_subdirectory(channeldocker) + add_subdirectory(artisticcolorselector) + add_subdirectory(tasksetdocker) + add_subdirectory(compositiondocker) + add_subdirectory(patterndocker) + add_subdirectory(griddocker) + add_subdirectory(arrangedocker) + if(HAVE_OCIO AND NOT ANDROID AND NOT HAIKU) + add_subdirectory(lut) + endif() + add_subdirectory(overview) + add_subdirectory(palettedocker) + add_subdirectory(animation) + add_subdirectory(presethistory) + add_subdirectory(svgcollectiondocker) + add_subdirectory(histogram) + add_subdirectory(gamutmask) + add_subdirectory(recorder) + add_subdirectory(logdocker) + add_subdirectory(snapshotdocker) + add_subdirectory(storyboarddocker) + add_subdirectory(widegamutcolorselector) + add_subdirectory(textproperties) +endif() diff --git a/plugins/filters/CMakeLists.txt b/plugins/filters/CMakeLists.txt index 91625abf9a..2d65b45fc4 100644 --- a/plugins/filters/CMakeLists.txt +++ b/plugins/filters/CMakeLists.txt @@ -1,34 +1,37 @@ -add_subdirectory( tests ) add_subdirectory( blur ) add_subdirectory( colors ) add_subdirectory( colorsfilters ) add_subdirectory( convolutionfilters ) -add_subdirectory( gaussianhighpass) -add_subdirectory( embossfilter ) -add_subdirectory( example ) -add_subdirectory( fastcolortransfer ) add_subdirectory( imageenhancement ) -add_subdirectory( noisefilter ) -add_subdirectory( oilpaintfilter ) -add_subdirectory( pixelizefilter ) -add_subdirectory( raindropsfilter ) -add_subdirectory( randompickfilter ) -add_subdirectory( roundcorners ) -add_subdirectory( smalltilesfilter ) add_subdirectory( unsharp ) -add_subdirectory( wavefilter ) add_subdirectory( levelfilter ) -add_subdirectory( dodgeburn ) -add_subdirectory( phongbumpmap ) add_subdirectory( posterize ) -add_subdirectory( indexcolors ) add_subdirectory( normalize ) -add_subdirectory( gradientmap ) add_subdirectory( threshold ) -add_subdirectory( halftone ) add_subdirectory( edgedetection ) -add_subdirectory( convertheightnormalmap ) -add_subdirectory( asccdl ) -add_subdirectory( palettize ) -add_subdirectory( resettransparent ) -add_subdirectory( propagatecolors ) + +if(NOT KRITA_EINK_SLIM) + add_subdirectory( tests ) + add_subdirectory( gaussianhighpass) + add_subdirectory( embossfilter ) + add_subdirectory( example ) + add_subdirectory( fastcolortransfer ) + add_subdirectory( noisefilter ) + add_subdirectory( oilpaintfilter ) + add_subdirectory( pixelizefilter ) + add_subdirectory( raindropsfilter ) + add_subdirectory( randompickfilter ) + add_subdirectory( roundcorners ) + add_subdirectory( smalltilesfilter ) + add_subdirectory( wavefilter ) + add_subdirectory( dodgeburn ) + add_subdirectory( phongbumpmap ) + add_subdirectory( indexcolors ) + add_subdirectory( gradientmap ) + add_subdirectory( halftone ) + add_subdirectory( convertheightnormalmap ) + add_subdirectory( asccdl ) + add_subdirectory( palettize ) + add_subdirectory( resettransparent ) + add_subdirectory( propagatecolors ) +endif() diff --git a/plugins/impex/CMakeLists.txt b/plugins/impex/CMakeLists.txt index e2758ac756..1f15fb0c2e 100644 --- a/plugins/impex/CMakeLists.txt +++ b/plugins/impex/CMakeLists.txt @@ -1,63 +1,54 @@ project(kritafilters) add_subdirectory(libkra) - if(JPEG_FOUND AND HAVE_LCMS2) - add_subdirectory(jpeg) -endif() - -if(TIFF_FOUND AND JPEG_FOUND) - add_subdirectory(tiff) + add_subdirectory(jpeg) endif() - if(PNG_FOUND) add_subdirectory(png) - add_subdirectory(csv) -endif() - -if(OpenEXR_FOUND) - add_subdirectory(exr) endif() - -if(Poppler_Qt${QT_MAJOR_VERSION}_FOUND) - add_subdirectory(pdf) -endif() - -if(OpenJPEG_FOUND AND NOT ${CMAKE_SYSTEM_NAME} STREQUAL "FreeBSD") - add_subdirectory(jp2) -endif() - -if(KF5KDcraw_FOUND OR KDcrawQt6_FOUND) - add_subdirectory(raw) -endif() - -add_subdirectory(svg) add_subdirectory(qimageio) add_subdirectory(ora) -add_subdirectory(psd) -add_subdirectory(qml) -add_subdirectory(tga) -add_subdirectory(heightmap) add_subdirectory(brush) -add_subdirectory(spriter) add_subdirectory(kra) - -if (GIF_FOUND) - add_subdirectory(gif) -endif() - -if (HEIF_FOUND) - add_subdirectory(heif) -endif() - add_subdirectory(krz) -if (WebP_FOUND) - add_subdirectory(webp) +if(NOT KRITA_EINK_SLIM) + if(TIFF_FOUND AND JPEG_FOUND) + add_subdirectory(tiff) + endif() + if(PNG_FOUND) + add_subdirectory(csv) + endif() + if(OpenEXR_FOUND) + add_subdirectory(exr) + endif() + if(Poppler_Qt${QT_MAJOR_VERSION}_FOUND) + add_subdirectory(pdf) + endif() + if(OpenJPEG_FOUND AND NOT ${CMAKE_SYSTEM_NAME} STREQUAL "FreeBSD") + add_subdirectory(jp2) + endif() + if(KF5KDcraw_FOUND OR KDcrawQt6_FOUND) + add_subdirectory(raw) + endif() + add_subdirectory(svg) + add_subdirectory(psd) + add_subdirectory(qml) + add_subdirectory(tga) + add_subdirectory(heightmap) + add_subdirectory(spriter) + if(GIF_FOUND) + add_subdirectory(gif) + endif() + if(HEIF_FOUND) + add_subdirectory(heif) + endif() + if(WebP_FOUND) + add_subdirectory(webp) + endif() + if(JPEGXL_FOUND) + add_subdirectory(jxl) + endif() + add_subdirectory(rgbe) endif() - -if (JPEGXL_FOUND) - add_subdirectory(jxl) -endif() - -add_subdirectory(rgbe) diff --git a/plugins/paintops/CMakeLists.txt b/plugins/paintops/CMakeLists.txt index ae0b64d5d9..1073b86054 100644 --- a/plugins/paintops/CMakeLists.txt +++ b/plugins/paintops/CMakeLists.txt @@ -2,20 +2,23 @@ include_directories(libpaintop) add_subdirectory( libpaintop ) add_subdirectory( defaultpaintops ) -add_subdirectory( hairy ) -add_subdirectory( deform ) -add_subdirectory( curvebrush ) -add_subdirectory( spray ) -add_subdirectory( filterop ) -add_subdirectory( experiment ) -add_subdirectory( particle ) -add_subdirectory( gridbrush ) -add_subdirectory( hatching) -add_subdirectory( sketch ) -add_subdirectory( colorsmudge ) add_subdirectory( roundmarker ) -add_subdirectory( tangentnormal ) -if(LibMyPaint_FOUND) +if (NOT KRITA_EINK_SLIM) + add_subdirectory( hairy ) + add_subdirectory( deform ) + add_subdirectory( curvebrush ) + add_subdirectory( spray ) + add_subdirectory( filterop ) + add_subdirectory( experiment ) + add_subdirectory( particle ) + add_subdirectory( gridbrush ) + add_subdirectory( hatching) + add_subdirectory( sketch ) + add_subdirectory( colorsmudge ) + add_subdirectory( tangentnormal ) +endif() + +if(NOT KRITA_EINK_SLIM AND LibMyPaint_FOUND) add_subdirectory( mypaint ) endif() diff --git a/plugins/tools/CMakeLists.txt b/plugins/tools/CMakeLists.txt index abf3ba2558..9549961fb0 100644 --- a/plugins/tools/CMakeLists.txt +++ b/plugins/tools/CMakeLists.txt @@ -2,16 +2,19 @@ add_subdirectory( basictools ) add_subdirectory( defaulttool ) add_subdirectory( selectiontools ) add_subdirectory( tool_crop ) -add_subdirectory( tool_polygon ) -add_subdirectory( tool_polyline ) add_subdirectory( tool_transform2 ) -add_subdirectory( tool_dyna ) -add_subdirectory( karbonplugins ) -add_subdirectory( tool_lazybrush ) -add_subdirectory( tool_smart_patch ) -add_subdirectory( svgtexttool ) -add_subdirectory( tool_enclose_and_fill ) -add_subdirectory( tool_knife ) + +if (NOT KRITA_EINK_SLIM) + add_subdirectory( tool_polygon ) + add_subdirectory( tool_polyline ) + add_subdirectory( tool_dyna ) + add_subdirectory( karbonplugins ) + add_subdirectory( tool_lazybrush ) + add_subdirectory( tool_smart_patch ) + add_subdirectory( svgtexttool ) + add_subdirectory( tool_enclose_and_fill ) + add_subdirectory( tool_knife ) +endif() install( FILES diff --git a/EINK_SLIM.md b/EINK_SLIM.md new file mode 100644 index 0000000000..d42a7c9760 --- /dev/null +++ b/EINK_SLIM.md @@ -0,0 +1,17 @@ +# E Ink slim build and Xiaomi adapter + +Configure the general E Ink build with `-DKRITA_EINK_SLIM=ON`. It keeps the raster painting core, layers, the default and round-marker brush engines, brush presets, the freehand brush stabilizer, pressure and tilt events, selection, crop and transform tools, common blur and color filters, KRA, KRZ, ORA, PNG and JPEG support. It excludes tests, updates, assistants, Python, generators, animation, advanced brush engines, advanced dockers and uncommon importers. + +The slim build forces Krita Light and replaces the advanced color selector with a 16-step monochrome selector. It includes continuous Syncthing export and Google Drive OAuth with PKCE. uNote preserves Android pressure and tilt data and aggregates partial-refresh damage. + +`-DKRITA_XIAOMI_EINK=ON` packages optional Xiaomi hardware support but never acquires the panel-global EBC overlay automatically. It needs the explicit `krita_eink/xiaomi_overlay_experimental` preference after per-device geometry and waveform calibration. Stylus events continue to Qt unchanged, including pressure and tilt. The default path remains the normal Android compositor so a failed or conflicting private overlay cannot leave the panel in a broken state. + +The Xiaomi adapter supports only `arm64-v8a` because the recovered vendor library is ARM64. The slim build itself remains usable without that adapter and falls back to normal Android rendering. + +Krita's normal Android dependency toolchain is still required. Add this to the existing Android CMake arguments. + +```text +-DKRITA_EINK_SLIM=ON -DBUILD_TESTING=OFF -DENABLE_UPDATERS=OFF +``` + +Add `-DKRITA_XIAOMI_EINK=ON` and export `KRITA_XIAOMI_EINK=1` only for the Xiaomi Android package. Gradle then restricts the package to ARM64 and bundles the recovered native E Ink library from `packaging/android/apk/lib/arm64-v8a`. diff --git a/packaging/android/apk/.gitignore b/packaging/android/apk/.gitignore new file mode 100644 index 0000000000..abc78ba75a --- /dev/null +++ b/packaging/android/apk/.gitignore @@ -0,0 +1,2 @@ +/.gradle/ +/build/ diff --git a/packaging/android/apk/settings.gradle b/packaging/android/apk/settings.gradle new file mode 100644 index 0000000000..8e0bdcbb55 --- /dev/null +++ b/packaging/android/apk/settings.gradle @@ -0,0 +1,9 @@ +pluginManagement { + repositories { + google() + mavenCentral() + gradlePluginPortal() + } +} + +rootProject.name = "krita-build-apk" diff --git a/packaging/android/apk/lib/arm64-v8a/libfakeRKEpdUtils.so b/packaging/android/apk/lib/arm64-v8a/libfakeRKEpdUtils.so new file mode 100644 index 0000000000000000000000000000000000000000..e730040b53e0cb4eb6bdb443d069c4d550012a99 GIT binary patch literal 22592 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 literal 0 HcmV?d00001 diff --git a/packaging/android/apk/src/com/moan/fakerkepdlib/EpdUtil.java b/packaging/android/apk/src/com/moan/fakerkepdlib/EpdUtil.java new file mode 100644 index 0000000000..694e122052 --- /dev/null +++ b/packaging/android/apk/src/com/moan/fakerkepdlib/EpdUtil.java @@ -0,0 +1,57 @@ +package com.moan.fakerkepdlib; + +import android.graphics.Bitmap; +import android.graphics.PointF; +import android.graphics.RectF; + +/** Binary-compatible binding reconstructed from MoanNote Smali. */ +public final class EpdUtil { + static { System.loadLibrary("fakeRKEpdUtils"); } + private EpdUtil() {} + private static native int JniCall_ClearOverlayBuffer(int id, int color, int sendOsd); + private static native int JniCall_CommitOverlay(Bitmap bitmap, int id, float left, float top, float right, float bottom, float x, float y); + private static native int JniCall_ObtainEBC(float[] bounds); + private static native void JniCall_ReleaseEBC(int id); + private static native int JniCall_SetOverLayBgControl(int id, int mode); + private static native int JniCall_SetOverLayCommit(int id, int enabled); + private static native int JniCall_test(int[] values); + + public static EBCData sInitEBC() { + float[] bounds = new float[4]; + int id = JniCall_ObtainEBC(bounds); + if (id < 0) return null; + EBCData data = new EBCData(); + data.mDataID = id; + data.mCommitBufRect.set(bounds[0], bounds[1], bounds[2], bounds[3]); + return data; + } + public static void sRecycleEBC(EBCData data) { if (data != null) JniCall_ReleaseEBC(data.mDataID); } + public static int sSetFastBgRefresh(EBCData data, boolean enabled) { return JniCall_SetOverLayBgControl(data.mDataID, enabled ? 0 : 1); } + public static int sSetFastHandWrite(EBCData data, boolean enabled) { return JniCall_SetOverLayCommit(data.mDataID, enabled ? 1 : 0); } + public static int sCommitColor(EBCData data, EBCCommitRequest request, EBCCommitResult result) { + return JniCall_ClearOverlayBuffer(data.mDataID, request.mCommitColor, request.mSendOsd); + } + public static int sCommitImage(EBCData data, EBCCommitRequest request, EBCCommitResult result) { + int code = request.mCommitType == EBCCommitRequest.COMMIT_CLEAR_COLOR + ? JniCall_ClearOverlayBuffer(data.mDataID, request.mCommitColor, request.mSendOsd) + : JniCall_CommitOverlay(request.mCommitImg, data.mDataID, request.mCommitSrcRect.left, + request.mCommitSrcRect.top, request.mCommitSrcRect.right, request.mCommitSrcRect.bottom, + request.mCommitPt.x, request.mCommitPt.y); + if (code > 0) result.mResultCode = 0; + return code; + } + public static final class EBCData { + public int mDataID; + public final RectF mCommitBufRect = new RectF(); + public int getEBCRect(RectF destination) { destination.set(mCommitBufRect); return 0; } + } + public static final class EBCCommitResult { public int mResultCode; } + public static final class EBCCommitRequest { + public static final int COMMIT_NORMAL = 0, COMMIT_WITH_SCALE = 1, COMMIT_WITH_ROTATE = 2, COMMIT_CLEAR_COLOR = 4; + public int mCommitColor, mCommitType, mSendOsd; + public final RectF mCommitDstRect = new RectF(); + public Bitmap mCommitImg; + public final PointF mCommitPt = new PointF(); + public final RectF mCommitSrcRect = new RectF(); + } +} diff --git a/packaging/android/apk/src/org/krita/android/XiaomiEinkController.java b/packaging/android/apk/src/org/krita/android/XiaomiEinkController.java new file mode 100644 index 0000000000..7ef635c4ff --- /dev/null +++ b/packaging/android/apk/src/org/krita/android/XiaomiEinkController.java @@ -0,0 +1,91 @@ +/* SPDX-License-Identifier: GPL-2.0-or-later */ +package org.krita.android; + +import android.graphics.Bitmap; +import android.graphics.Rect; +import android.os.Handler; +import android.os.HandlerThread; +import android.util.Log; +import android.view.MotionEvent; +import android.view.PixelCopy; +import android.view.Window; + +import com.moan.fakerkepdlib.EpdUtil; +import rip.crit.ms.unote.DamageTracker; +import rip.crit.ms.unote.PenInput; +import rip.crit.ms.unote.PenSample; + +/** Sends pressure-aware dirty regions from Krita's Qt surface to Xiaomi's E Ink overlay. */ +final class XiaomiEinkController implements AutoCloseable { + private static final String TAG = "krita.XiaomiEink"; + private final Window window; + private final HandlerThread worker = new HandlerThread("krita-eink-commit"); + private final Handler handler; + private final EpdUtil.EBCData buffer; + private final DamageTracker pending = new DamageTracker(); + private boolean captureScheduled; + private boolean closed; + + XiaomiEinkController(Window window) { + this.window = window; + worker.start(); + handler = new Handler(worker.getLooper()); + buffer = EpdUtil.sInitEBC(); + if (buffer == null) throw new IllegalStateException("Xiaomi E Ink buffer unavailable"); + EpdUtil.sSetFastBgRefresh(buffer, true); + EpdUtil.sSetFastHandWrite(buffer, true); + } + + void onMotionEvent(MotionEvent event) { + if (closed || event.getPointerCount() == 0) return; + final int index = event.getActionIndex(); + synchronized (pending) { + for (PenSample sample : PenInput.samples(event)) { + pending.add(sample, 18f, 42f, 8f); + } + if (!captureScheduled) { + captureScheduled = true; + handler.postDelayed(this::capture, event.getActionMasked() == MotionEvent.ACTION_UP ? 0 : 16); + } + } + } + + private void capture() { + final int width = window.getDecorView().getWidth(); + final int height = window.getDecorView().getHeight(); + if (closed || width <= 0 || height <= 0) return; + final Rect dirty; + synchronized (pending) { + dirty = pending.take(width, height); + captureScheduled = false; + } + if (dirty.isEmpty()) return; + final Bitmap frame = Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888); + PixelCopy.request(window, frame, result -> { + try { + if (result == PixelCopy.SUCCESS && !closed) commit(frame, dirty); + else Log.w(TAG, "PixelCopy failed with code " + result); + } finally { + frame.recycle(); + } + }, handler); + } + + private void commit(Bitmap frame, Rect dirty) { + EpdUtil.EBCCommitRequest request = new EpdUtil.EBCCommitRequest(); + request.mCommitImg = frame; + request.mCommitSrcRect.set(dirty); + request.mCommitPt.set(dirty.left, dirty.top); + int result = EpdUtil.sCommitImage(buffer, request, new EpdUtil.EBCCommitResult()); + if (result < 0) Log.w(TAG, "E Ink commit failed with code " + result); + } + + @Override public void close() { + if (closed) return; + closed = true; + EpdUtil.sSetFastHandWrite(buffer, false); + EpdUtil.sSetFastBgRefresh(buffer, false); + EpdUtil.sRecycleEBC(buffer); + worker.quitSafely(); + } +} diff --git a/plugins/dockers/einkmonochromeselector/CMakeLists.txt b/plugins/dockers/einkmonochromeselector/CMakeLists.txt new file mode 100644 index 0000000000..7a5d4f02b5 --- /dev/null +++ b/plugins/dockers/einkmonochromeselector/CMakeLists.txt @@ -0,0 +1,3 @@ +kis_add_library(kritaeinkmonochromeselector MODULE einkmonochromeselector.cpp) +target_link_libraries(kritaeinkmonochromeselector kritaui) +install(TARGETS kritaeinkmonochromeselector DESTINATION ${KRITA_PLUGIN_INSTALL_DIR}) diff --git a/plugins/dockers/einkmonochromeselector/einkmonochromeselector.cpp b/plugins/dockers/einkmonochromeselector/einkmonochromeselector.cpp new file mode 100644 index 0000000000..dd7f50cb0d --- /dev/null +++ b/plugins/dockers/einkmonochromeselector/einkmonochromeselector.cpp @@ -0,0 +1,73 @@ +/* SPDX-License-Identifier: GPL-2.0-or-later */ +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +class EinkMonochromeDock final : public QDockWidget, public KisMainwindowObserver +{ + Q_OBJECT +public: + EinkMonochromeDock() : QDockWidget(i18n("E Ink Grays")) { + auto *panel = new QWidget(this); + auto *layout = new QGridLayout(panel); + layout->setSpacing(3); + for (int step = 0; step < 16; ++step) { + const int gray = 255 - step * 17; + auto *button = new QPushButton(QString::number(step), panel); + button->setMinimumSize(44, 44); + button->setStyleSheet(QString("background:#%1%1%1;color:%2") + .arg(gray, 2, 16, QLatin1Char('0')).arg(gray < 128 ? "white" : "black")); + button->setAccessibleName(i18n("Gray level %1 of 15", step)); + connect(button, &QPushButton::clicked, this, [this, gray] { + if (!m_view) return; + KoColor color = m_view->canvasResourceProvider()->fgColor(); + color.fromQColor(QColor(gray, gray, gray)); + m_view->canvasResourceProvider()->slotSetFGColor(color); + }); + layout->addWidget(button, step / 4, step % 4); + } + setWidget(panel); + } + QString observerName() override { return QStringLiteral("EinkMonochromeDock"); } + void setCanvas(KoCanvasBase *canvas) override { setEnabled(canvas != nullptr); } + void unsetCanvas() override { setEnabled(false); } + void setViewManager(KisViewManager *view) override { m_view = view; } +private: + KisViewManager *m_view = nullptr; +}; + +class EinkMonochromeFactory final : public KoDockFactoryBase +{ +public: + QString id() const override { return QStringLiteral("EinkMonochromeSelector"); } + Qt::DockWidgetArea defaultDockWidgetArea() const { return Qt::RightDockWidgetArea; } + QDockWidget *createDockWidget() override { + auto *dock = new EinkMonochromeDock(); + dock->setObjectName(id()); + return dock; + } + DockPosition defaultDockPosition() const override { return DockMinimized; } +}; + +class EinkMonochromePlugin final : public QObject +{ + Q_OBJECT +public: + EinkMonochromePlugin(QObject *parent, const QVariantList &) : QObject(parent) { + KoDockRegistry::instance()->add(new EinkMonochromeFactory()); + } +}; + +K_PLUGIN_FACTORY_WITH_JSON(EinkMonochromePluginFactory, "krita_einkmonochromeselector.json", registerPlugin();) +#include "einkmonochromeselector.moc" diff --git a/plugins/dockers/einkmonochromeselector/krita_einkmonochromeselector.json b/plugins/dockers/einkmonochromeselector/krita_einkmonochromeselector.json new file mode 100644 index 0000000000..494e2cfeed --- /dev/null +++ b/plugins/dockers/einkmonochromeselector/krita_einkmonochromeselector.json @@ -0,0 +1,7 @@ +{ + "Id": "EinkMonochromeSelector", + "Type": "Service", + "X-KDE-Library": "kritaeinkmonochromeselector", + "X-KDE-ServiceTypes": ["Krita/Dock"], + "X-Krita-Version": "28" +} diff --git a/plugins/extensions/einksync/CMakeLists.txt b/plugins/extensions/einksync/CMakeLists.txt new file mode 100644 index 0000000000..eaabbecb80 --- /dev/null +++ b/plugins/extensions/einksync/CMakeLists.txt @@ -0,0 +1,5 @@ +set(kritaeinksync_SOURCES EinkSyncPlugin.cpp) +kis_add_library(kritaeinksync MODULE ${kritaeinksync_SOURCES}) +target_link_libraries(kritaeinksync kritaui Qt${QT_MAJOR_VERSION}::Network) +install(TARGETS kritaeinksync DESTINATION ${KRITA_PLUGIN_INSTALL_DIR}) +install(FILES EinkSyncPlugin.action DESTINATION ${KDE_INSTALL_DATADIR}/krita/actions) diff --git a/plugins/extensions/einksync/EinkSyncPlugin.action b/plugins/extensions/einksync/EinkSyncPlugin.action new file mode 100644 index 0000000000..b714a5941c --- /dev/null +++ b/plugins/extensions/einksync/EinkSyncPlugin.action @@ -0,0 +1,13 @@ + + + + E Ink Sync + + E Ink Sync Settings... + Configure Syncthing folder and Google Drive export + + + Sync Current Document Now + + + diff --git a/plugins/extensions/einksync/EinkSyncPlugin.cpp b/plugins/extensions/einksync/EinkSyncPlugin.cpp new file mode 100644 index 0000000000..3aaafe0b15 --- /dev/null +++ b/plugins/extensions/einksync/EinkSyncPlugin.cpp @@ -0,0 +1,264 @@ +/* SPDX-License-Identifier: GPL-2.0-or-later */ +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +class EinkSyncPlugin final : public KisActionPlugin +{ + Q_OBJECT +public: + EinkSyncPlugin(QObject *parent, const QVariantList &) : KisActionPlugin(parent) { + connect(viewManager()->actionManager()->createAction("eink_sync_settings"), &QAction::triggered, + this, &EinkSyncPlugin::showSettings); + connect(viewManager()->actionManager()->createAction("eink_sync_now"), &QAction::triggered, + this, [this] { syncFile(viewManager()->document() ? viewManager()->document()->path() : QString()); }); + connect(KisPart::instance(), &KisPart::sigDocumentSaved, this, &EinkSyncPlugin::queueSync); + m_debounce.setSingleShot(true); + m_debounce.setInterval(1200); + connect(&m_debounce, &QTimer::timeout, this, [this] { syncFile(m_pendingPath); }); + } + +private Q_SLOTS: + void queueSync(const QString &path) { + m_pendingPath = path; + m_debounce.start(); + } + + void showSettings() { + KConfigGroup cfg(KSharedConfig::openConfig(), "EinkSync"); + QDialog dialog(viewManager()->mainWindow()); + dialog.setWindowTitle(i18n("E Ink Continuous Sync")); + auto *layout = new QFormLayout(&dialog); + auto *enabled = new QCheckBox(i18n("Sync after every successful save"), &dialog); + enabled->setChecked(cfg.readEntry("Enabled", true)); + auto *folder = new QLineEdit(cfg.readEntry("SyncthingFolder", QString()), &dialog); + folder->setPlaceholderText(i18n("App-accessible Syncthing folder")); + auto *stUrl = new QLineEdit(cfg.readEntry("SyncthingUrl", "http://127.0.0.1:8384"), &dialog); + auto *stKey = new QLineEdit(cfg.readEntry("SyncthingApiKey", QString()), &dialog); + stKey->setEchoMode(QLineEdit::Password); + auto *client = new QLineEdit(cfg.readEntry("GoogleClientId", QString()), &dialog); + client->setPlaceholderText(i18n("Google OAuth desktop client ID")); + auto *google = new QPushButton(i18n("Authorize Google Drive"), &dialog); + layout->addRow(enabled); + layout->addRow(i18n("Syncthing folder"), folder); + layout->addRow(i18n("Syncthing address"), stUrl); + layout->addRow(i18n("Syncthing API key"), stKey); + layout->addRow(i18n("Google client ID"), client); + layout->addRow(google); + auto *buttons = new QDialogButtonBox(QDialogButtonBox::Save | QDialogButtonBox::Cancel, &dialog); + layout->addRow(buttons); + connect(google, &QPushButton::clicked, &dialog, [this, client] { beginGoogleLocalAuthorization(client->text()); }); + connect(buttons, &QDialogButtonBox::accepted, &dialog, &QDialog::accept); + connect(buttons, &QDialogButtonBox::rejected, &dialog, &QDialog::reject); + if (dialog.exec() == QDialog::Accepted) { + cfg.writeEntry("Enabled", enabled->isChecked()); + cfg.writeEntry("SyncthingFolder", folder->text().trimmed()); + cfg.writeEntry("SyncthingUrl", stUrl->text().trimmed()); + cfg.writeEntry("SyncthingApiKey", stKey->text().trimmed()); + cfg.writeEntry("GoogleClientId", client->text().trimmed()); + cfg.sync(); + } + } + +private: + void syncFile(const QString &path) { + KConfigGroup cfg(KSharedConfig::openConfig(), "EinkSync"); + if (!cfg.readEntry("Enabled", true) || path.isEmpty() || !QFileInfo::exists(path)) return; + const QString folder = cfg.readEntry("SyncthingFolder", QString()); + if (!folder.isEmpty()) exportToSyncthing(path, folder); + const QString refreshToken = cfg.readEntry("GoogleRefreshToken", QString()); + if (!refreshToken.isEmpty()) refreshGoogleTokenAndUpload(path, refreshToken, cfg.readEntry("GoogleClientId", QString())); + } + + // Shared Syncthing contract (mirrors SyncthingHelper used by Notable + // and Share File): stage the file into the Syncthing folder, then ask + // the local Syncthing REST endpoint to rescan so peers converge. + void exportToSyncthing(const QString &source, const QString &folder) { + if (!QDir().mkpath(folder)) return; + QFile input(source); + QSaveFile output(QDir(folder).filePath(QFileInfo(source).fileName())); + if (!input.open(QIODevice::ReadOnly) || !output.open(QIODevice::WriteOnly)) return; + while (!input.atEnd()) output.write(input.read(1024 * 1024)); + if (!output.commit()) return; + KConfigGroup cfg(KSharedConfig::openConfig(), "EinkSync"); + const QString base = cfg.readEntry("SyncthingUrl", QStringLiteral("http://127.0.0.1:8384")); + QNetworkRequest scan{QUrl(base + QStringLiteral("/rest/db/scan?folder=") + + QString::fromUtf8(QUrl::toPercentEncoding(QFileInfo(source).fileName())))}; + const QString key = cfg.readEntry("SyncthingApiKey", QString()); + if (!key.isEmpty()) scan.setRawHeader("X-API-Key", key.toUtf8()); + auto *reply = m_network.post(scan, QByteArray()); + connect(reply, &QNetworkReply::finished, reply, &QNetworkReply::deleteLater); + } + + void beginGoogleLocalAuthorization(const QString &clientId) { + if (clientId.isEmpty()) return; + m_clientId = clientId; + delete m_oauthServer; + m_oauthServer = new QTcpServer(this); + if (!m_oauthServer->listen(QHostAddress::LocalHost, 0)) { + QMessageBox::warning(viewManager()->mainWindow(), i18n("Google Drive"), i18n("Could not open the local OAuth callback port")); + return; + } + m_oauthState = QUuid::createUuid().toString(QUuid::WithoutBraces); + m_pkceVerifier = QUuid::createUuid().toString(QUuid::WithoutBraces) + + QUuid::createUuid().toString(QUuid::WithoutBraces); + const QByteArray challenge = QCryptographicHash::hash(m_pkceVerifier.toUtf8(), QCryptographicHash::Sha256) + .toBase64(QByteArray::Base64UrlEncoding | QByteArray::OmitTrailingEquals); + m_redirectUri = QString("http://127.0.0.1:%1/oauth2/callback").arg(m_oauthServer->serverPort()); + + connect(m_oauthServer, &QTcpServer::newConnection, this, [this] { + QTcpSocket *socket = m_oauthServer->nextPendingConnection(); + connect(socket, &QTcpSocket::readyRead, this, [this, socket] { + const QByteArray firstLine = socket->readLine(); + const QList parts = firstLine.split(' '); + QUrl callback(parts.size() > 1 ? QString("http://127.0.0.1") + QString::fromUtf8(parts[1]) : QString()); + QUrlQuery query(callback); + const bool valid = query.queryItemValue("state") == m_oauthState && !query.queryItemValue("code").isEmpty(); + const QByteArray message = valid + ? "HTTP/1.1 200 OK\r\nContent-Type: text/plain\r\nConnection: close\r\n\r\nAuthorization complete. Return to Krita." + : "HTTP/1.1 400 Bad Request\r\nContent-Type: text/plain\r\nConnection: close\r\n\r\nAuthorization failed. Return to Krita."; + socket->write(message); + socket->disconnectFromHost(); + m_oauthServer->close(); + if (!valid) return; + QUrlQuery form; + form.addQueryItem("client_id", m_clientId); + form.addQueryItem("code", query.queryItemValue("code")); + form.addQueryItem("code_verifier", m_pkceVerifier); + form.addQueryItem("redirect_uri", m_redirectUri); + form.addQueryItem("grant_type", "authorization_code"); + tokenRequest(form, [this](const QJsonObject &data) { + const QString refresh = data.value("refresh_token").toString(); + if (refresh.isEmpty()) { + QMessageBox::warning(viewManager()->mainWindow(), i18n("Google Drive"), i18n("Google returned no refresh token")); + return; + } + KConfigGroup cfg(KSharedConfig::openConfig(), "EinkSync"); + cfg.writeEntry("GoogleRefreshToken", refresh); + cfg.sync(); + QMessageBox::information(viewManager()->mainWindow(), i18n("Google Drive"), i18n("Authorization complete")); + }); + }); + }); + + QUrl authorization("https://accounts.google.com/o/oauth2/v2/auth"); + QUrlQuery query; + query.addQueryItem("client_id", clientId); + query.addQueryItem("redirect_uri", m_redirectUri); + query.addQueryItem("response_type", "code"); + query.addQueryItem("scope", "https://www.googleapis.com/auth/drive.file"); + query.addQueryItem("code_challenge", QString::fromLatin1(challenge)); + query.addQueryItem("code_challenge_method", "S256"); + query.addQueryItem("state", m_oauthState); + query.addQueryItem("access_type", "offline"); + query.addQueryItem("prompt", "consent"); + authorization.setQuery(query); + QDesktopServices::openUrl(authorization); + } + + void refreshGoogleTokenAndUpload(const QString &path, const QString &refreshToken, const QString &clientId) { + QUrlQuery form; + form.addQueryItem("client_id", clientId); + form.addQueryItem("refresh_token", refreshToken); + form.addQueryItem("grant_type", "refresh_token"); + tokenRequest(form, [this, path](const QJsonObject &data) { + const QString token = data.value("access_token").toString(); + if (!token.isEmpty()) uploadGoogleDrive(path, token); + }); + } + + void tokenRequest(const QUrlQuery &form, std::function callback) { + QNetworkRequest request{QUrl("https://oauth2.googleapis.com/token")}; + request.setHeader(QNetworkRequest::ContentTypeHeader, "application/x-www-form-urlencoded"); + auto *reply = m_network.post(request, form.toString(QUrl::FullyEncoded).toUtf8()); + connect(reply, &QNetworkReply::finished, this, [reply, callback] { + const QJsonObject data = QJsonDocument::fromJson(reply->readAll()).object(); + reply->deleteLater(); + callback(data); + }); + } + + void uploadGoogleDrive(const QString &path, const QString &accessToken) { + auto *file = new QFile(path); + if (!file->open(QIODevice::ReadOnly)) { delete file; return; } + auto *body = new QHttpMultiPart(QHttpMultiPart::RelatedType); + QHttpPart metadata; + metadata.setHeader(QNetworkRequest::ContentTypeHeader, "application/json; charset=UTF-8"); + metadata.setBody(QJsonDocument(QJsonObject{{"name", QFileInfo(path).fileName()}}).toJson(QJsonDocument::Compact)); + QHttpPart media; + media.setHeader(QNetworkRequest::ContentTypeHeader, "application/octet-stream"); + media.setBodyDevice(file); + file->setParent(body); + body->append(metadata); + body->append(media); + KConfigGroup cfg(KSharedConfig::openConfig(), "EinkSync"); + const QString idKey = "GoogleFileId_" + QString::fromLatin1(QCryptographicHash::hash(path.toUtf8(), QCryptographicHash::Sha256).toHex()); + const QString fileId = cfg.readEntry(idKey, QString()); + const QString endpoint = fileId.isEmpty() + ? QStringLiteral("https://www.googleapis.com/upload/drive/v3/files?uploadType=multipart") + : QStringLiteral("https://www.googleapis.com/upload/drive/v3/files/%1?uploadType=multipart").arg(fileId); + QNetworkRequest request{QUrl(endpoint)}; + request.setRawHeader("Authorization", QByteArray("Bearer ") + accessToken.toUtf8()); + QNetworkReply *reply = fileId.isEmpty() + ? m_network.post(request, body) + : m_network.sendCustomRequest(request, "PATCH", body); + body->setParent(reply); + connect(reply, &QNetworkReply::finished, this, [reply, idKey] { + const QJsonObject response = QJsonDocument::fromJson(reply->readAll()).object(); + const QString createdId = response.value("id").toString(); + if (!createdId.isEmpty()) { + KConfigGroup cfg(KSharedConfig::openConfig(), "EinkSync"); + cfg.writeEntry(idKey, createdId); + cfg.sync(); + } + reply->deleteLater(); + }); + } + + QNetworkAccessManager m_network; + QTimer m_debounce; + QString m_pendingPath; + QString m_clientId; + QString m_oauthState; + QString m_pkceVerifier; + QString m_redirectUri; + QTcpServer *m_oauthServer = nullptr; +}; + +K_PLUGIN_FACTORY_WITH_JSON(EinkSyncFactory, "kritaeinksync.json", registerPlugin();) +#include "EinkSyncPlugin.moc" diff --git a/plugins/extensions/einksync/kritaeinksync.json b/plugins/extensions/einksync/kritaeinksync.json new file mode 100644 index 0000000000..1ef752cd45 --- /dev/null +++ b/plugins/extensions/einksync/kritaeinksync.json @@ -0,0 +1,7 @@ +{ + "Id": "EinkSync", + "Type": "Service", + "X-KDE-Library": "kritaeinksync", + "X-KDE-ServiceTypes": ["Krita/ViewPlugin"], + "X-Krita-Version": "28" +}