OpenMiInk/krita-patches/xiaomi-eink.patch
Hellings b8c8ff98e2 Hi
2026-10-16 09:05:56 -04:00

1419 lines
61 KiB
Diff

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 @@
<?xml version='1.0' encoding='utf-8'?>
-<manifest package="org.krita" xmlns:android="http://schemas.android.com/apk/res/android" android:installLocation="auto">
+<manifest package="rip.crit.ms.org.krita" xmlns:android="http://schemas.android.com/apk/res/android" android:installLocation="auto">
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
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 <methods>; }
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<UNoteEinkController> 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 <QDockWidget>
+#include <QColor>
+#include <QGridLayout>
+#include <QPushButton>
+#include <QLatin1Char>
+#include <QWidget>
+#include <KisViewManager.h>
+#include <kis_canvas_resource_provider.h>
+#include <kis_mainwindow_observer.h>
+#include <KoColor.h>
+#include <KoDockFactoryBase.h>
+#include <KoDockRegistry.h>
+#include <kpluginfactory.h>
+#include <klocalizedstring.h>
+
+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<EinkMonochromePlugin>();)
+#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 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<ActionCollection version="2" name="Krita">
+ <Actions category="Tools">
+ <text>E Ink Sync</text>
+ <Action name="eink_sync_settings">
+ <text>E Ink Sync Settings...</text>
+ <toolTip>Configure Syncthing folder and Google Drive export</toolTip>
+ </Action>
+ <Action name="eink_sync_now">
+ <text>Sync Current Document Now</text>
+ </Action>
+ </Actions>
+</ActionCollection>
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 <KisActionPlugin.h>
+#include <KisDocument.h>
+#include <KisMainWindow.h>
+#include <KisPart.h>
+#include <KisViewManager.h>
+#include <kis_action.h>
+#include <kis_action_manager.h>
+#include <kconfiggroup.h>
+#include <kpluginfactory.h>
+#include <ksharedconfig.h>
+#include <klocalizedstring.h>
+#include <QCheckBox>
+#include <QCryptographicHash>
+#include <QDesktopServices>
+#include <QDialog>
+#include <QDialogButtonBox>
+#include <QDir>
+#include <QFile>
+#include <QFileInfo>
+#include <QFormLayout>
+#include <QHttpMultiPart>
+#include <QHostAddress>
+#include <QJsonDocument>
+#include <QJsonObject>
+#include <QLabel>
+#include <QLineEdit>
+#include <QMessageBox>
+#include <QNetworkAccessManager>
+#include <QNetworkReply>
+#include <QNetworkRequest>
+#include <QPushButton>
+#include <QSaveFile>
+#include <QTimer>
+#include <QTcpServer>
+#include <QTcpSocket>
+#include <QUuid>
+#include <QUrlQuery>
+#include <functional>
+
+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<QByteArray> 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<void(const QJsonObject &)> 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<EinkSyncPlugin>();)
+#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"
+}