From d5d5c1bed226b78590b85dbc94bafab019c77615 Mon Sep 17 00:00:00 2001 From: Heron Date: Fri, 15 Aug 2025 16:29:31 +0800 Subject: [PATCH 01/47] Add image_gallery_saver plugin configuration files --- plugins/image_gallery_saver/.gitignore | 13 + plugins/image_gallery_saver/.metadata | 10 + plugins/image_gallery_saver/.travis.yaml | 19 ++ plugins/image_gallery_saver/.travis.yml | 19 ++ plugins/image_gallery_saver/CHANGELOG.md | 106 ++++++++ plugins/image_gallery_saver/LICENSE | 21 ++ plugins/image_gallery_saver/README.md | 81 ++++++ .../image_gallery_saver/android/.gitignore | 8 + .../image_gallery_saver/android/build.gradle | 45 ++++ .../android/gradle.properties | 3 + .../android/gradle/wrapper/gradle-wrapper.jar | Bin 0 -> 54329 bytes .../gradle/wrapper/gradle-wrapper.properties | 6 + plugins/image_gallery_saver/android/gradlew | 172 ++++++++++++ .../image_gallery_saver/android/gradlew.bat | 84 ++++++ .../android/settings.gradle | 1 + .../android/src/main/AndroidManifest.xml | 3 + .../ImageGallerySaverPlugin.kt | 246 ++++++++++++++++++ plugins/image_gallery_saver/ios/.gitignore | 36 +++ .../image_gallery_saver/ios/Assets/.gitkeep | 0 .../ios/Classes/ImageGallerySaverPlugin.h | 4 + .../ios/Classes/ImageGallerySaverPlugin.m | 13 + .../SwiftImageGallerySaverPlugin.swift | 194 ++++++++++++++ .../ios/image_gallery_saver.podspec | 23 ++ .../lib/image_gallery_saver.dart | 38 +++ plugins/image_gallery_saver/pubspec.yaml | 31 +++ 25 files changed, 1176 insertions(+) create mode 100644 plugins/image_gallery_saver/.gitignore create mode 100644 plugins/image_gallery_saver/.metadata create mode 100644 plugins/image_gallery_saver/.travis.yaml create mode 100644 plugins/image_gallery_saver/.travis.yml create mode 100644 plugins/image_gallery_saver/CHANGELOG.md create mode 100644 plugins/image_gallery_saver/LICENSE create mode 100644 plugins/image_gallery_saver/README.md create mode 100644 plugins/image_gallery_saver/android/.gitignore create mode 100644 plugins/image_gallery_saver/android/build.gradle create mode 100644 plugins/image_gallery_saver/android/gradle.properties create mode 100644 plugins/image_gallery_saver/android/gradle/wrapper/gradle-wrapper.jar create mode 100644 plugins/image_gallery_saver/android/gradle/wrapper/gradle-wrapper.properties create mode 100644 plugins/image_gallery_saver/android/gradlew create mode 100644 plugins/image_gallery_saver/android/gradlew.bat create mode 100644 plugins/image_gallery_saver/android/settings.gradle create mode 100644 plugins/image_gallery_saver/android/src/main/AndroidManifest.xml create mode 100644 plugins/image_gallery_saver/android/src/main/kotlin/com/example/imagegallerysaver/ImageGallerySaverPlugin.kt create mode 100644 plugins/image_gallery_saver/ios/.gitignore create mode 100644 plugins/image_gallery_saver/ios/Assets/.gitkeep create mode 100644 plugins/image_gallery_saver/ios/Classes/ImageGallerySaverPlugin.h create mode 100644 plugins/image_gallery_saver/ios/Classes/ImageGallerySaverPlugin.m create mode 100644 plugins/image_gallery_saver/ios/Classes/SwiftImageGallerySaverPlugin.swift create mode 100644 plugins/image_gallery_saver/ios/image_gallery_saver.podspec create mode 100644 plugins/image_gallery_saver/lib/image_gallery_saver.dart create mode 100644 plugins/image_gallery_saver/pubspec.yaml diff --git a/plugins/image_gallery_saver/.gitignore b/plugins/image_gallery_saver/.gitignore new file mode 100644 index 000000000..b365c0809 --- /dev/null +++ b/plugins/image_gallery_saver/.gitignore @@ -0,0 +1,13 @@ +.DS_Store +.dart_tool/ + +.packages +.pub/ +.idea/ +.fvm/ + +pubspec.lock + +build/ + +*.iml \ No newline at end of file diff --git a/plugins/image_gallery_saver/.metadata b/plugins/image_gallery_saver/.metadata new file mode 100644 index 000000000..51497f398 --- /dev/null +++ b/plugins/image_gallery_saver/.metadata @@ -0,0 +1,10 @@ +# This file tracks properties of this Flutter project. +# Used by Flutter tool to assess capabilities and perform upgrades etc. +# +# This file should be version controlled and should not be manually edited. + +version: + revision: 6a3ff018b199a7febbe2b5adbb564081d8f49e2f + channel: dev + +project_type: plugin diff --git a/plugins/image_gallery_saver/.travis.yaml b/plugins/image_gallery_saver/.travis.yaml new file mode 100644 index 000000000..fc0d22e84 --- /dev/null +++ b/plugins/image_gallery_saver/.travis.yaml @@ -0,0 +1,19 @@ +os: + - linux +sudo: false +addons: + apt: + # Flutter depends on /usr/lib/x86_64-linux-gnu/libstdc++.so.6 version GLIBCXX_3.4.18 + sources: + - ubuntu-toolchain-r-test # if we don't specify this, the libstdc++6 we get is the wrong version + packages: + - libstdc++6 + - fonts-droid-fallback +before_script: + - git clone https://github.com/flutter/flutter.git -b stable --depth 1 + - ./flutter/bin/flutter doctor +script: + - ./flutter/bin/flutter test +cache: + directories: + - $HOME/.pub-cache diff --git a/plugins/image_gallery_saver/.travis.yml b/plugins/image_gallery_saver/.travis.yml new file mode 100644 index 000000000..fc0d22e84 --- /dev/null +++ b/plugins/image_gallery_saver/.travis.yml @@ -0,0 +1,19 @@ +os: + - linux +sudo: false +addons: + apt: + # Flutter depends on /usr/lib/x86_64-linux-gnu/libstdc++.so.6 version GLIBCXX_3.4.18 + sources: + - ubuntu-toolchain-r-test # if we don't specify this, the libstdc++6 we get is the wrong version + packages: + - libstdc++6 + - fonts-droid-fallback +before_script: + - git clone https://github.com/flutter/flutter.git -b stable --depth 1 + - ./flutter/bin/flutter doctor +script: + - ./flutter/bin/flutter test +cache: + directories: + - $HOME/.pub-cache diff --git a/plugins/image_gallery_saver/CHANGELOG.md b/plugins/image_gallery_saver/CHANGELOG.md new file mode 100644 index 000000000..aa84ee06a --- /dev/null +++ b/plugins/image_gallery_saver/CHANGELOG.md @@ -0,0 +1,106 @@ +## 2.0.3 +- 1.Upgrade flutter version to 3.10.5 +- 2.Android build tools are upgraded to 7.3.0 +- 3.Optimize the Android plugin library code + +## 2.0.2 +- 1.Optimization android plugin + +## 2.0.1 +- 1.Upgrade flutter version to 3.10.2 +- 2.Upgrade Android/ios plug-in related +- 3.Support Android 13 +- 4.Support ios16 + +## 1.7.1 +- optimization + +## 1.7.0 +- optimization + +## 1.6.9 +- optimization + +## 1.6.8 +- Support android 11 save + +## 1.6.7 +- fix ios bug + +## 1.6.6 +* fix ios bug + +## 1.6.5 +* fix android bug + +## 1.6.4 +* formatted code + +## 1.6.3 +* Save result return more message + +## 1.6.2 +* fix crash on iOS when granting permission + +## 1.6.1 +* fix iOS Swift5.1 error + +## 1.6.0 +* Support iOS return save path + +## 1.5.0 +* Save image with JPG and Support special quality(ios & Android) +* Support special Image name for Android +* Upgrade libraries and dependence +* fix docs +* Add more example + +## 1.3.0 + +* Define clang module for static ios builds +* Cleanup example project + +## 1.2.2 + +* Migrate to AndroidX +* optimize git ignore + +## 1.2.1 + +* Support return path for Android. +* Fix bug(save video fail for Android). + +## 1.2.0 + +* Support video save and file path to gallery +* Add example for save video and net image + +## 1.1.0 + +* Upgrade kotlin(1.3.20) and gradle build plugin version(3.3.0). + +## 1.0.0 + +* Updated Kotlin Gradle plugin version + +## 0.1.2 + +* Remove hard coded path - image_gallery_saver in Android + +## 0.1.1 + +* Updated README and Description + +## 0.1.0 + +* Updated README and CHANGELOG +* Add LICENSE +* Add Test + +## 0.0.2 + +* Updated README and CHANGELOG + +## 0.0.1 + +* Initial Open Source release. diff --git a/plugins/image_gallery_saver/LICENSE b/plugins/image_gallery_saver/LICENSE new file mode 100644 index 000000000..c72f3cdd1 --- /dev/null +++ b/plugins/image_gallery_saver/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) [2023] [zaihui] + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/plugins/image_gallery_saver/README.md b/plugins/image_gallery_saver/README.md new file mode 100644 index 000000000..49bd2a788 --- /dev/null +++ b/plugins/image_gallery_saver/README.md @@ -0,0 +1,81 @@ +# image_gallery_saver + +[![Build Status](https://travis-ci.org/hui-z/image_gallery_saver.svg?branch=master)](https://travis-ci.org/hui-z/image_gallery_saver#) +[![pub package](https://img.shields.io/pub/v/image_gallery_saver.svg)](https://pub.dartlang.org/packages/image_gallery_saver) +[![license](https://img.shields.io/github/license/mashape/apistatus.svg)](https://choosealicense.com/licenses/mit/) + +We use the `image_picker` plugin to select images from the Android and iOS image library, but it can't save images to the gallery. This plugin can provide this feature. + +## Usage + +To use this plugin, add `image_gallery_saver` as a dependency in your pubspec.yaml file. For example: +```yaml +dependencies: + image_gallery_saver: '^2.0.3' +``` + +## iOS +Your project need create with swift. +Add the following keys to your Info.plist file, located in /ios/Runner/Info.plist: + * NSPhotoLibraryAddUsageDescription - describe why your app needs permission for the photo library. This is called Privacy - Photo Library Additions Usage Description in the visual editor + + ## Android + You need to ask for storage permission to save an image to the gallery. You can handle the storage permission using [flutter_permission_handler](https://github.com/BaseflowIT/flutter-permission-handler). + In Android version 10, Open the manifest file and add this line to your application tag + ``` + + ``` + +## Example +Saving an image from the internet, quality and name is option +``` dart + _saveLocalImage() async { + RenderRepaintBoundary boundary = + _globalKey.currentContext!.findRenderObject() as RenderRepaintBoundary; + ui.Image image = await boundary.toImage(); + ByteData? byteData = + await (image.toByteData(format: ui.ImageByteFormat.png)); + if (byteData != null) { + final result = + await ImageGallerySaver.saveImage(byteData.buffer.asUint8List()); + print(result); + } + } + + _saveNetworkImage() async { + var response = await Dio().get( + "https://ss0.baidu.com/94o3dSag_xI4khGko9WTAnF6hhy/image/h%3D300/sign=a62e824376d98d1069d40a31113eb807/838ba61ea8d3fd1fc9c7b6853a4e251f94ca5f46.jpg", + options: Options(responseType: ResponseType.bytes)); + final result = await ImageGallerySaver.saveImage( + Uint8List.fromList(response.data), + quality: 60, + name: "hello"); + print(result); + } +``` + +Saving file(ig: video/gif/others) from the internet +``` dart + _saveNetworkGifFile() async { + var appDocDir = await getTemporaryDirectory(); + String savePath = appDocDir.path + "/temp.gif"; + String fileUrl = + "https://hyjdoc.oss-cn-beijing.aliyuncs.com/hyj-doc-flutter-demo-run.gif"; + await Dio().download(fileUrl, savePath); + final result = + await ImageGallerySaver.saveFile(savePath, isReturnPathOfIOS: true); + print(result); + } + + _saveNetworkVideoFile() async { + var appDocDir = await getTemporaryDirectory(); + String savePath = appDocDir.path + "/temp.mp4"; + String fileUrl = + "https://s3.cn-north-1.amazonaws.com.cn/mtab.kezaihui.com/video/ForBiggerBlazes.mp4"; + await Dio().download(fileUrl, savePath, onReceiveProgress: (count, total) { + print((count / total * 100).toStringAsFixed(0) + "%"); + }); + final result = await ImageGallerySaver.saveFile(savePath); + print(result); + } +``` diff --git a/plugins/image_gallery_saver/android/.gitignore b/plugins/image_gallery_saver/android/.gitignore new file mode 100644 index 000000000..c6cbe562a --- /dev/null +++ b/plugins/image_gallery_saver/android/.gitignore @@ -0,0 +1,8 @@ +*.iml +.gradle +/local.properties +/.idea/workspace.xml +/.idea/libraries +.DS_Store +/build +/captures diff --git a/plugins/image_gallery_saver/android/build.gradle b/plugins/image_gallery_saver/android/build.gradle new file mode 100644 index 000000000..7ec7a091f --- /dev/null +++ b/plugins/image_gallery_saver/android/build.gradle @@ -0,0 +1,45 @@ +group 'com.example.imagegallerysaver' +version '1.0-SNAPSHOT' + +buildscript { + ext.kotlin_version = '1.7.10' + repositories { + google() + mavenCentral() + } + + dependencies { + classpath 'com.android.tools.build:gradle:7.3.0' + classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version" + } +} + +rootProject.allprojects { + repositories { + google() + mavenCentral() + } +} + +apply plugin: 'com.android.library' +apply plugin: 'kotlin-android' + +android { + namespace 'com.example.imagegallerysaver' + compileSdkVersion 30 + + sourceSets { + main.java.srcDirs += 'src/main/kotlin' + } + defaultConfig { + minSdkVersion 16 + testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" + } + lintOptions { + disable 'InvalidPackage' + } +} + +dependencies { + implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk7:$kotlin_version" +} diff --git a/plugins/image_gallery_saver/android/gradle.properties b/plugins/image_gallery_saver/android/gradle.properties new file mode 100644 index 000000000..53ae0ae47 --- /dev/null +++ b/plugins/image_gallery_saver/android/gradle.properties @@ -0,0 +1,3 @@ +android.enableJetifier=true +android.useAndroidX=true +org.gradle.jvmargs=-Xmx1536M diff --git a/plugins/image_gallery_saver/android/gradle/wrapper/gradle-wrapper.jar b/plugins/image_gallery_saver/android/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 0000000000000000000000000000000000000000..f6b961fd5a86aa5fbfe90f707c3138408be7c718 GIT binary patch literal 54329 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

<5G2M1WZP5 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 F{|7XC?P&l2 literal 0 HcmV?d00001 diff --git a/plugins/image_gallery_saver/android/gradle/wrapper/gradle-wrapper.properties b/plugins/image_gallery_saver/android/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 000000000..cf40b29a7 --- /dev/null +++ b/plugins/image_gallery_saver/android/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,6 @@ +#Mon Sep 09 11:20:21 CST 2019 +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-7.5-all.zip diff --git a/plugins/image_gallery_saver/android/gradlew b/plugins/image_gallery_saver/android/gradlew new file mode 100644 index 000000000..cccdd3d51 --- /dev/null +++ b/plugins/image_gallery_saver/android/gradlew @@ -0,0 +1,172 @@ +#!/usr/bin/env sh + +############################################################################## +## +## Gradle start up script for UN*X +## +############################################################################## + +# Attempt to set APP_HOME +# Resolve links: $0 may be a link +PRG="$0" +# Need this for relative symlinks. +while [ -h "$PRG" ] ; do + ls=`ls -ld "$PRG"` + link=`expr "$ls" : '.*-> \(.*\)$'` + if expr "$link" : '/.*' > /dev/null; then + PRG="$link" + else + PRG=`dirname "$PRG"`"/$link" + fi +done +SAVED="`pwd`" +cd "`dirname \"$PRG\"`/" >/dev/null +APP_HOME="`pwd -P`" +cd "$SAVED" >/dev/null + +APP_NAME="Gradle" +APP_BASE_NAME=`basename "$0"` + +# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +DEFAULT_JVM_OPTS="" + +# Use the maximum available, or set MAX_FD != -1 to use that value. +MAX_FD="maximum" + +warn () { + echo "$*" +} + +die () { + echo + echo "$*" + echo + exit 1 +} + +# OS specific support (must be 'true' or 'false'). +cygwin=false +msys=false +darwin=false +nonstop=false +case "`uname`" in + CYGWIN* ) + cygwin=true + ;; + Darwin* ) + darwin=true + ;; + MINGW* ) + msys=true + ;; + NONSTOP* ) + nonstop=true + ;; +esac + +CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar + +# Determine the Java command to use to start the JVM. +if [ -n "$JAVA_HOME" ] ; then + if [ -x "$JAVA_HOME/jre/sh/java" ] ; then + # IBM's JDK on AIX uses strange locations for the executables + JAVACMD="$JAVA_HOME/jre/sh/java" + else + JAVACMD="$JAVA_HOME/bin/java" + fi + if [ ! -x "$JAVACMD" ] ; then + die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +else + JAVACMD="java" + which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." +fi + +# Increase the maximum file descriptors if we can. +if [ "$cygwin" = "false" -a "$darwin" = "false" -a "$nonstop" = "false" ] ; then + MAX_FD_LIMIT=`ulimit -H -n` + if [ $? -eq 0 ] ; then + if [ "$MAX_FD" = "maximum" -o "$MAX_FD" = "max" ] ; then + MAX_FD="$MAX_FD_LIMIT" + fi + ulimit -n $MAX_FD + if [ $? -ne 0 ] ; then + warn "Could not set maximum file descriptor limit: $MAX_FD" + fi + else + warn "Could not query maximum file descriptor limit: $MAX_FD_LIMIT" + fi +fi + +# For Darwin, add options to specify how the application appears in the dock +if $darwin; then + GRADLE_OPTS="$GRADLE_OPTS \"-Xdock:name=$APP_NAME\" \"-Xdock:icon=$APP_HOME/media/gradle.icns\"" +fi + +# For Cygwin, switch paths to Windows format before running java +if $cygwin ; then + APP_HOME=`cygpath --path --mixed "$APP_HOME"` + CLASSPATH=`cygpath --path --mixed "$CLASSPATH"` + JAVACMD=`cygpath --unix "$JAVACMD"` + + # We build the pattern for arguments to be converted via cygpath + ROOTDIRSRAW=`find -L / -maxdepth 1 -mindepth 1 -type d 2>/dev/null` + SEP="" + for dir in $ROOTDIRSRAW ; do + ROOTDIRS="$ROOTDIRS$SEP$dir" + SEP="|" + done + OURCYGPATTERN="(^($ROOTDIRS))" + # Add a user-defined pattern to the cygpath arguments + if [ "$GRADLE_CYGPATTERN" != "" ] ; then + OURCYGPATTERN="$OURCYGPATTERN|($GRADLE_CYGPATTERN)" + fi + # Now convert the arguments - kludge to limit ourselves to /bin/sh + i=0 + for arg in "$@" ; do + CHECK=`echo "$arg"|egrep -c "$OURCYGPATTERN" -` + CHECK2=`echo "$arg"|egrep -c "^-"` ### Determine if an option + + if [ $CHECK -ne 0 ] && [ $CHECK2 -eq 0 ] ; then ### Added a condition + eval `echo args$i`=`cygpath --path --ignore --mixed "$arg"` + else + eval `echo args$i`="\"$arg\"" + fi + i=$((i+1)) + done + case $i in + (0) set -- ;; + (1) set -- "$args0" ;; + (2) set -- "$args0" "$args1" ;; + (3) set -- "$args0" "$args1" "$args2" ;; + (4) set -- "$args0" "$args1" "$args2" "$args3" ;; + (5) set -- "$args0" "$args1" "$args2" "$args3" "$args4" ;; + (6) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" ;; + (7) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" ;; + (8) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" ;; + (9) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" "$args8" ;; + esac +fi + +# Escape application args +save () { + for i do printf %s\\n "$i" | sed "s/'/'\\\\''/g;1s/^/'/;\$s/\$/' \\\\/" ; done + echo " " +} +APP_ARGS=$(save "$@") + +# Collect all arguments for the java command, following the shell quoting and substitution rules +eval set -- $DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS "\"-Dorg.gradle.appname=$APP_BASE_NAME\"" -classpath "\"$CLASSPATH\"" org.gradle.wrapper.GradleWrapperMain "$APP_ARGS" + +# by default we should be in the correct project dir, but when run from Finder on Mac, the cwd is wrong +if [ "$(uname)" = "Darwin" ] && [ "$HOME" = "$PWD" ]; then + cd "$(dirname "$0")" +fi + +exec "$JAVACMD" "$@" diff --git a/plugins/image_gallery_saver/android/gradlew.bat b/plugins/image_gallery_saver/android/gradlew.bat new file mode 100644 index 000000000..f9553162f --- /dev/null +++ b/plugins/image_gallery_saver/android/gradlew.bat @@ -0,0 +1,84 @@ +@if "%DEBUG%" == "" @echo off +@rem ########################################################################## +@rem +@rem Gradle startup script for Windows +@rem +@rem ########################################################################## + +@rem Set local scope for the variables with windows NT shell +if "%OS%"=="Windows_NT" setlocal + +set DIRNAME=%~dp0 +if "%DIRNAME%" == "" set DIRNAME=. +set APP_BASE_NAME=%~n0 +set APP_HOME=%DIRNAME% + +@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +set DEFAULT_JVM_OPTS= + +@rem Find java.exe +if defined JAVA_HOME goto findJavaFromJavaHome + +set JAVA_EXE=java.exe +%JAVA_EXE% -version >NUL 2>&1 +if "%ERRORLEVEL%" == "0" goto init + +echo. +echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. +echo. +echo Please set the JAVA_HOME variable in your environment to match the +echo location of your Java installation. + +goto fail + +:findJavaFromJavaHome +set JAVA_HOME=%JAVA_HOME:"=% +set JAVA_EXE=%JAVA_HOME%/bin/java.exe + +if exist "%JAVA_EXE%" goto init + +echo. +echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% +echo. +echo Please set the JAVA_HOME variable in your environment to match the +echo location of your Java installation. + +goto fail + +:init +@rem Get command-line arguments, handling Windows variants + +if not "%OS%" == "Windows_NT" goto win9xME_args + +:win9xME_args +@rem Slurp the command line arguments. +set CMD_LINE_ARGS= +set _SKIP=2 + +:win9xME_args_slurp +if "x%~1" == "x" goto execute + +set CMD_LINE_ARGS=%* + +:execute +@rem Setup the command line + +set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar + +@rem Execute Gradle +"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %CMD_LINE_ARGS% + +:end +@rem End local scope for the variables with windows NT shell +if "%ERRORLEVEL%"=="0" goto mainEnd + +:fail +rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of +rem the _cmd.exe /c_ return code! +if not "" == "%GRADLE_EXIT_CONSOLE%" exit 1 +exit /b 1 + +:mainEnd +if "%OS%"=="Windows_NT" endlocal + +:omega diff --git a/plugins/image_gallery_saver/android/settings.gradle b/plugins/image_gallery_saver/android/settings.gradle new file mode 100644 index 000000000..632310dd2 --- /dev/null +++ b/plugins/image_gallery_saver/android/settings.gradle @@ -0,0 +1 @@ +rootProject.name = 'image_gallery_saver' diff --git a/plugins/image_gallery_saver/android/src/main/AndroidManifest.xml b/plugins/image_gallery_saver/android/src/main/AndroidManifest.xml new file mode 100644 index 000000000..5f1f81368 --- /dev/null +++ b/plugins/image_gallery_saver/android/src/main/AndroidManifest.xml @@ -0,0 +1,3 @@ + + diff --git a/plugins/image_gallery_saver/android/src/main/kotlin/com/example/imagegallerysaver/ImageGallerySaverPlugin.kt b/plugins/image_gallery_saver/android/src/main/kotlin/com/example/imagegallerysaver/ImageGallerySaverPlugin.kt new file mode 100644 index 000000000..649bc4323 --- /dev/null +++ b/plugins/image_gallery_saver/android/src/main/kotlin/com/example/imagegallerysaver/ImageGallerySaverPlugin.kt @@ -0,0 +1,246 @@ +package com.example.imagegallerysaver + +import androidx.annotation.NonNull +import android.annotation.TargetApi +import android.content.ContentValues +import android.content.Context +import android.content.Intent +import android.graphics.Bitmap +import android.graphics.BitmapFactory +import android.media.MediaScannerConnection +import android.net.Uri +import android.os.Environment +import android.os.Build +import android.provider.MediaStore +import io.flutter.embedding.engine.plugins.FlutterPlugin +import io.flutter.plugin.common.BinaryMessenger +import io.flutter.plugin.common.MethodCall +import io.flutter.plugin.common.MethodChannel +import io.flutter.plugin.common.MethodChannel.MethodCallHandler +import io.flutter.plugin.common.MethodChannel.Result +import java.io.File +import java.io.FileInputStream +import java.io.IOException +import android.text.TextUtils +import android.webkit.MimeTypeMap +import java.io.OutputStream + +class ImageGallerySaverPlugin : FlutterPlugin, MethodCallHandler { + private lateinit var methodChannel: MethodChannel + private var applicationContext: Context? = null + + override fun onAttachedToEngine(@NonNull binding: FlutterPlugin.FlutterPluginBinding) { + this.applicationContext = binding.applicationContext + methodChannel = MethodChannel(binding.binaryMessenger, "image_gallery_saver") + methodChannel.setMethodCallHandler(this) + } + + override fun onMethodCall(@NonNull call: MethodCall,@NonNull result: Result): Unit { + when (call.method) { + "saveImageToGallery" -> { + val image = call.argument("imageBytes") + val quality = call.argument("quality") + val name = call.argument("name") + + result.success( + saveImageToGallery( + BitmapFactory.decodeByteArray( + image ?: ByteArray(0), + 0, + image?.size ?: 0 + ), quality, name + ) + ) + } + + "saveFileToGallery" -> { + val path = call.argument("file") + val name = call.argument("name") + result.success(saveFileToGallery(path, name)) + } + + else -> result.notImplemented() + } + } + + override fun onDetachedFromEngine(@NonNull binding: FlutterPlugin.FlutterPluginBinding) { + applicationContext = null + methodChannel.setMethodCallHandler(null); + } + + private fun generateUri(extension: String = "", name: String? = null): Uri? { + var fileName = name ?: System.currentTimeMillis().toString() + val mimeType = getMIMEType(extension) + val isVideo = mimeType?.startsWith("video")==true + + return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { + // >= android 10 + val uri = when { + isVideo -> MediaStore.Video.Media.EXTERNAL_CONTENT_URI + else -> MediaStore.Images.Media.EXTERNAL_CONTENT_URI + } + + val values = ContentValues().apply { + put(MediaStore.MediaColumns.DISPLAY_NAME, fileName) + put( + MediaStore.MediaColumns.RELATIVE_PATH, when { + isVideo -> Environment.DIRECTORY_MOVIES + else -> Environment.DIRECTORY_PICTURES + } + ) + if (!TextUtils.isEmpty(mimeType)) { + put(when {isVideo -> MediaStore.Video.Media.MIME_TYPE + else -> MediaStore.Images.Media.MIME_TYPE + }, mimeType) + } + } + + applicationContext?.contentResolver?.insert(uri, values) + + } else { + // < android 10 + val storePath = + Environment.getExternalStoragePublicDirectory(when { + isVideo -> Environment.DIRECTORY_MOVIES + else -> Environment.DIRECTORY_PICTURES + }).absolutePath + val appDir = File(storePath).apply { + if (!exists()) { + mkdir() + } + } + + val file = + File(appDir, if (extension.isNotEmpty()) "$fileName.$extension" else fileName) + Uri.fromFile(file) + } + } + + /** + * get file Mime Type + * + * @param extension extension + * @return file Mime Type + */ + private fun getMIMEType(extension: String): String? { + return if (!TextUtils.isEmpty(extension)) { + MimeTypeMap.getSingleton().getMimeTypeFromExtension(extension.lowercase()) + } else { + null + } + } + + /** + * Send storage success notification + * + * @param context context + * @param fileUri file path + */ + private fun sendBroadcast(context: Context, fileUri: Uri?) { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.Q) { + val mediaScanIntent = Intent(Intent.ACTION_MEDIA_SCANNER_SCAN_FILE) + mediaScanIntent.data = fileUri + context.sendBroadcast(mediaScanIntent) + } + } + + private fun saveImageToGallery( + bmp: Bitmap?, + quality: Int?, + name: String? + ): HashMap { + // check parameters + if (bmp == null || quality == null) { + return SaveResultModel(false, null, "parameters error").toHashMap() + } + // check applicationContext + val context = applicationContext + ?: return SaveResultModel(false, null, "applicationContext null").toHashMap() + var fileUri: Uri? = null + var fos: OutputStream? = null + var success = false + try { + fileUri = generateUri("jpg", name = name) + if (fileUri != null) { + fos = context.contentResolver.openOutputStream(fileUri) + if (fos != null) { + println("ImageGallerySaverPlugin $quality") + bmp.compress(Bitmap.CompressFormat.JPEG, quality, fos) + fos.flush() + success = true + } + } + } catch (e: IOException) { + SaveResultModel(false, null, e.toString()).toHashMap() + } finally { + fos?.close() + bmp.recycle() + } + return if (success) { + sendBroadcast(context, fileUri) + SaveResultModel(fileUri.toString().isNotEmpty(), fileUri.toString(), null).toHashMap() + } else { + SaveResultModel(false, null, "saveImageToGallery fail").toHashMap() + } + } + + private fun saveFileToGallery(filePath: String?, name: String?): HashMap { + // check parameters + if (filePath == null) { + return SaveResultModel(false, null, "parameters error").toHashMap() + } + val context = applicationContext ?: return SaveResultModel( + false, + null, + "applicationContext null" + ).toHashMap() + var fileUri: Uri? = null + var outputStream: OutputStream? = null + var fileInputStream: FileInputStream? = null + var success = false + + try { + val originalFile = File(filePath) + if(!originalFile.exists()) return SaveResultModel(false, null, "$filePath does not exist").toHashMap() + fileUri = generateUri(originalFile.extension, name) + if (fileUri != null) { + outputStream = context.contentResolver?.openOutputStream(fileUri) + if (outputStream != null) { + fileInputStream = FileInputStream(originalFile) + + val buffer = ByteArray(10240) + var count = 0 + while (fileInputStream.read(buffer).also { count = it } > 0) { + outputStream.write(buffer, 0, count) + } + + outputStream.flush() + success = true + } + } + } catch (e: IOException) { + SaveResultModel(false, null, e.toString()).toHashMap() + } finally { + outputStream?.close() + fileInputStream?.close() + } + return if (success) { + sendBroadcast(context, fileUri) + SaveResultModel(fileUri.toString().isNotEmpty(), fileUri.toString(), null).toHashMap() + } else { + SaveResultModel(false, null, "saveFileToGallery fail").toHashMap() + } + } +} + +class SaveResultModel(var isSuccess: Boolean, + var filePath: String? = null, + var errorMessage: String? = null) { + fun toHashMap(): HashMap { + val hashMap = HashMap() + hashMap["isSuccess"] = isSuccess + hashMap["filePath"] = filePath + hashMap["errorMessage"] = errorMessage + return hashMap + } +} diff --git a/plugins/image_gallery_saver/ios/.gitignore b/plugins/image_gallery_saver/ios/.gitignore new file mode 100644 index 000000000..710ec6cf1 --- /dev/null +++ b/plugins/image_gallery_saver/ios/.gitignore @@ -0,0 +1,36 @@ +.idea/ +.vagrant/ +.sconsign.dblite +.svn/ + +.DS_Store +*.swp +profile + +DerivedData/ +build/ +GeneratedPluginRegistrant.h +GeneratedPluginRegistrant.m + +.generated/ + +*.pbxuser +*.mode1v3 +*.mode2v3 +*.perspectivev3 + +!default.pbxuser +!default.mode1v3 +!default.mode2v3 +!default.perspectivev3 + +xcuserdata + +*.moved-aside + +*.pyc +*sync/ +Icon? +.tags* + +/Flutter/Generated.xcconfig diff --git a/plugins/image_gallery_saver/ios/Assets/.gitkeep b/plugins/image_gallery_saver/ios/Assets/.gitkeep new file mode 100644 index 000000000..e69de29bb diff --git a/plugins/image_gallery_saver/ios/Classes/ImageGallerySaverPlugin.h b/plugins/image_gallery_saver/ios/Classes/ImageGallerySaverPlugin.h new file mode 100644 index 000000000..39b96a02f --- /dev/null +++ b/plugins/image_gallery_saver/ios/Classes/ImageGallerySaverPlugin.h @@ -0,0 +1,4 @@ +#import + +@interface ImageGallerySaverPlugin : NSObject +@end diff --git a/plugins/image_gallery_saver/ios/Classes/ImageGallerySaverPlugin.m b/plugins/image_gallery_saver/ios/Classes/ImageGallerySaverPlugin.m new file mode 100644 index 000000000..a9e65e35d --- /dev/null +++ b/plugins/image_gallery_saver/ios/Classes/ImageGallerySaverPlugin.m @@ -0,0 +1,13 @@ +#import "ImageGallerySaverPlugin.h" + +#if __has_include() +#import +#else +#import "image_gallery_saver-Swift.h" +#endif + +@implementation ImageGallerySaverPlugin ++ (void)registerWithRegistrar:(NSObject*)registrar { + [SwiftImageGallerySaverPlugin registerWithRegistrar:registrar]; +} +@end diff --git a/plugins/image_gallery_saver/ios/Classes/SwiftImageGallerySaverPlugin.swift b/plugins/image_gallery_saver/ios/Classes/SwiftImageGallerySaverPlugin.swift new file mode 100644 index 000000000..876c31873 --- /dev/null +++ b/plugins/image_gallery_saver/ios/Classes/SwiftImageGallerySaverPlugin.swift @@ -0,0 +1,194 @@ +import Flutter +import UIKit +import Photos + +public class SwiftImageGallerySaverPlugin: NSObject, FlutterPlugin { + let errorMessage = "Failed to save, please check whether the permission is enabled" + + var result: FlutterResult?; + + public static func register(with registrar: FlutterPluginRegistrar) { + let channel = FlutterMethodChannel(name: "image_gallery_saver", binaryMessenger: registrar.messenger()) + let instance = SwiftImageGallerySaverPlugin() + registrar.addMethodCallDelegate(instance, channel: channel) + } + + public func handle(_ call: FlutterMethodCall, result: @escaping FlutterResult) { + self.result = result + if call.method == "saveImageToGallery" { + let arguments = call.arguments as? [String: Any] ?? [String: Any]() + guard let imageData = (arguments["imageBytes"] as? FlutterStandardTypedData)?.data, + let image = UIImage(data: imageData), + let quality = arguments["quality"] as? Int, + let _ = arguments["name"], + let isReturnImagePath = arguments["isReturnImagePathOfIOS"] as? Bool + else { return } + let newImage = image.jpegData(compressionQuality: CGFloat(quality / 100))! + saveImage(UIImage(data: newImage) ?? image, isReturnImagePath: isReturnImagePath) + } else if (call.method == "saveFileToGallery") { + guard let arguments = call.arguments as? [String: Any], + let path = arguments["file"] as? String, + let _ = arguments["name"], + let isReturnFilePath = arguments["isReturnPathOfIOS"] as? Bool else { return } + if (isImageFile(filename: path)) { + saveImageAtFileUrl(path, isReturnImagePath: isReturnFilePath) + } else { + if (UIVideoAtPathIsCompatibleWithSavedPhotosAlbum(path)) { + saveVideo(path, isReturnImagePath: isReturnFilePath) + }else{ + self.saveResult(isSuccess:false,error:self.errorMessage) + } + } + } else { + result(FlutterMethodNotImplemented) + } + } + + func saveVideo(_ path: String, isReturnImagePath: Bool) { + if !isReturnImagePath { + UISaveVideoAtPathToSavedPhotosAlbum(path, self, #selector(didFinishSavingVideo(videoPath:error:contextInfo:)), nil) + return + } + var videoIds: [String] = [] + + PHPhotoLibrary.shared().performChanges( { + let req = PHAssetChangeRequest.creationRequestForAssetFromVideo(atFileURL: URL.init(fileURLWithPath: path)) + if let videoId = req?.placeholderForCreatedAsset?.localIdentifier { + videoIds.append(videoId) + } + }, completionHandler: { [unowned self] (success, error) in + DispatchQueue.main.async { + if (success && videoIds.count > 0) { + let assetResult = PHAsset.fetchAssets(withLocalIdentifiers: videoIds, options: nil) + if (assetResult.count > 0) { + let videoAsset = assetResult[0] + PHImageManager().requestAVAsset(forVideo: videoAsset, options: nil) { (avurlAsset, audioMix, info) in + if let urlStr = (avurlAsset as? AVURLAsset)?.url.absoluteString { + self.saveResult(isSuccess: true, filePath: urlStr) + } + } + } + } else { + self.saveResult(isSuccess: false, error: self.errorMessage) + } + } + }) + } + + func saveImage(_ image: UIImage, isReturnImagePath: Bool) { + if !isReturnImagePath { + UIImageWriteToSavedPhotosAlbum(image, self, #selector(didFinishSavingImage(image:error:contextInfo:)), nil) + return + } + + var imageIds: [String] = [] + + PHPhotoLibrary.shared().performChanges( { + let req = PHAssetChangeRequest.creationRequestForAsset(from: image) + if let imageId = req.placeholderForCreatedAsset?.localIdentifier { + imageIds.append(imageId) + } + }, completionHandler: { [unowned self] (success, error) in + DispatchQueue.main.async { + if (success && imageIds.count > 0) { + let assetResult = PHAsset.fetchAssets(withLocalIdentifiers: imageIds, options: nil) + if (assetResult.count > 0) { + let imageAsset = assetResult[0] + let options = PHContentEditingInputRequestOptions() + options.canHandleAdjustmentData = { (adjustmeta) + -> Bool in true } + imageAsset.requestContentEditingInput(with: options) { [unowned self] (contentEditingInput, info) in + if let urlStr = contentEditingInput?.fullSizeImageURL?.absoluteString { + self.saveResult(isSuccess: true, filePath: urlStr) + } + } + } + } else { + self.saveResult(isSuccess: false, error: self.errorMessage) + } + } + }) + } + + func saveImageAtFileUrl(_ url: String, isReturnImagePath: Bool) { + if !isReturnImagePath { + if let image = UIImage(contentsOfFile: url) { + UIImageWriteToSavedPhotosAlbum(image, self, #selector(didFinishSavingImage(image:error:contextInfo:)), nil) + } + return + } + + var imageIds: [String] = [] + + PHPhotoLibrary.shared().performChanges( { + let req = PHAssetChangeRequest.creationRequestForAssetFromImage(atFileURL: URL(string: url)!) + if let imageId = req?.placeholderForCreatedAsset?.localIdentifier { + imageIds.append(imageId) + } + }, completionHandler: { [unowned self] (success, error) in + DispatchQueue.main.async { + if (success && imageIds.count > 0) { + let assetResult = PHAsset.fetchAssets(withLocalIdentifiers: imageIds, options: nil) + if (assetResult.count > 0) { + let imageAsset = assetResult[0] + let options = PHContentEditingInputRequestOptions() + options.canHandleAdjustmentData = { (adjustmeta) + -> Bool in true } + imageAsset.requestContentEditingInput(with: options) { [unowned self] (contentEditingInput, info) in + if let urlStr = contentEditingInput?.fullSizeImageURL?.absoluteString { + self.saveResult(isSuccess: true, filePath: urlStr) + } + } + } + } else { + self.saveResult(isSuccess: false, error: self.errorMessage) + } + } + }) + } + + /// finish saving,if has error,parameters error will not nill + @objc func didFinishSavingImage(image: UIImage, error: NSError?, contextInfo: UnsafeMutableRawPointer?) { + saveResult(isSuccess: error == nil, error: error?.description) + } + + @objc func didFinishSavingVideo(videoPath: String, error: NSError?, contextInfo: UnsafeMutableRawPointer?) { + saveResult(isSuccess: error == nil, error: error?.description) + } + + func saveResult(isSuccess: Bool, error: String? = nil, filePath: String? = nil) { + var saveResult = SaveResultModel() + saveResult.isSuccess = error == nil + saveResult.errorMessage = error?.description + saveResult.filePath = filePath + result?(saveResult.toDic()) + } + + func isImageFile(filename: String) -> Bool { + return filename.hasSuffix(".jpg") + || filename.hasSuffix(".png") + || filename.hasSuffix(".jpeg") + || filename.hasSuffix(".JPEG") + || filename.hasSuffix(".JPG") + || filename.hasSuffix(".PNG") + || filename.hasSuffix(".gif") + || filename.hasSuffix(".GIF") + || filename.hasSuffix(".heic") + || filename.hasSuffix(".HEIC") + } +} + +public struct SaveResultModel: Encodable { + var isSuccess: Bool! + var filePath: String? + var errorMessage: String? + + func toDic() -> [String:Any]? { + let encoder = JSONEncoder() + guard let data = try? encoder.encode(self) else { return nil } + if (!JSONSerialization.isValidJSONObject(data)) { + return try? JSONSerialization.jsonObject(with: data, options: .mutableContainers) as? [String:Any] + } + return nil + } +} diff --git a/plugins/image_gallery_saver/ios/image_gallery_saver.podspec b/plugins/image_gallery_saver/ios/image_gallery_saver.podspec new file mode 100644 index 000000000..e27e6d17b --- /dev/null +++ b/plugins/image_gallery_saver/ios/image_gallery_saver.podspec @@ -0,0 +1,23 @@ +# +# To learn more about a Podspec see http://guides.cocoapods.org/syntax/podspec.html +# +Pod::Spec.new do |s| + s.name = 'image_gallery_saver' + s.version = '2.0.2' + s.summary = 'A new flutter plugin project.' + s.description = <<-DESC +A new flutter plugin project. + DESC + s.homepage = 'http://example.com' + s.license = { :file => '../LICENSE' } + s.author = { 'Your Company' => 'email@example.com' } + s.source = { :path => '.' } + s.source_files = 'Classes/**/*' + s.public_header_files = 'Classes/**/*.h' + s.dependency 'Flutter' + + s.ios.deployment_target = '8.0' + s.swift_version = '5.0' + s.pod_target_xcconfig = { 'DEFINES_MODULE' => 'YES', 'VALID_ARCHS[sdk=iphonesimulator*]' => 'x86_64' } +end + diff --git a/plugins/image_gallery_saver/lib/image_gallery_saver.dart b/plugins/image_gallery_saver/lib/image_gallery_saver.dart new file mode 100644 index 000000000..ffb12f2de --- /dev/null +++ b/plugins/image_gallery_saver/lib/image_gallery_saver.dart @@ -0,0 +1,38 @@ +import 'dart:async'; + +import 'package:flutter/services.dart'; + +class ImageGallerySaver { + static const MethodChannel _channel = + const MethodChannel('image_gallery_saver'); + + /// save image to Gallery + /// imageBytes can't null + /// return Map type + /// for example:{"isSuccess":true, "filePath":String?} + static FutureOr saveImage(Uint8List imageBytes, + {int quality = 80, + String? name, + bool isReturnImagePathOfIOS = false}) async { + final result = + await _channel.invokeMethod('saveImageToGallery', { + 'imageBytes': imageBytes, + 'quality': quality, + 'name': name, + 'isReturnImagePathOfIOS': isReturnImagePathOfIOS + }); + return result; + } + + /// Save the PNG,JPG,JPEG image or video located at [file] to the local device media gallery. + static Future saveFile(String file, + {String? name, bool isReturnPathOfIOS = false}) async { + final result = await _channel.invokeMethod( + 'saveFileToGallery', { + 'file': file, + 'name': name, + 'isReturnPathOfIOS': isReturnPathOfIOS + }); + return result; + } +} diff --git a/plugins/image_gallery_saver/pubspec.yaml b/plugins/image_gallery_saver/pubspec.yaml new file mode 100644 index 000000000..9ca158199 --- /dev/null +++ b/plugins/image_gallery_saver/pubspec.yaml @@ -0,0 +1,31 @@ +name: image_gallery_saver +description: A flutter plugin for save image to gallery, iOS need to add the following keys to your Info.plist file. +version: 2.0.3 +homepage: https://github.com/hui-z/image_gallery_saver + +environment: + sdk: '>=2.12.0 <4.0.0' + flutter: ">=1.17.0" + +dependencies: + flutter: + sdk: flutter + + +# For information on the generic Dart part of this file, see the +# following page: https://www.dartlang.org/tools/pub/pubspec + +# The following section is specific to Flutter. +flutter: + plugin: + platforms: + android: + package: com.example.imagegallerysaver + pluginClass: ImageGallerySaverPlugin + ios: + pluginClass: ImageGallerySaverPlugin + +dev_dependencies: + flutter_test: + sdk: flutter + From 132f75599e3a523b19c005aaec2d585b3f7fcd84 Mon Sep 17 00:00:00 2001 From: Heron Date: Fri, 15 Aug 2025 17:40:21 +0800 Subject: [PATCH 02/47] feat(wallet): auto-create default NKN wallet on first launch; support empty password in auth/sign-in; add settings flag --- ios/Runner.xcodeproj/project.pbxproj | 16 ++++++++++++---- lib/common/client/client.dart | 6 +++--- lib/common/wallet/wallet.dart | 19 +++++++++---------- lib/main.dart | 27 +++++++++++++++++++++++++++ lib/storages/settings.dart | 1 + pubspec.yaml | 2 +- 6 files changed, 53 insertions(+), 18 deletions(-) diff --git a/ios/Runner.xcodeproj/project.pbxproj b/ios/Runner.xcodeproj/project.pbxproj index 06bee2602..0ce639287 100644 --- a/ios/Runner.xcodeproj/project.pbxproj +++ b/ios/Runner.xcodeproj/project.pbxproj @@ -3,7 +3,7 @@ archiveVersion = 1; classes = { }; - objectVersion = 54; + objectVersion = 77; objects = { /* Begin PBXBuildFile section */ @@ -465,10 +465,14 @@ inputFileListPaths = ( "${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks-${CONFIGURATION}-input-files.xcfilelist", ); + inputPaths = ( + ); name = "[CP] Embed Pods Frameworks"; outputFileListPaths = ( "${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks-${CONFIGURATION}-output-files.xcfilelist", ); + outputPaths = ( + ); runOnlyForDeploymentPostprocessing = 0; shellPath = /bin/sh; shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks.sh\"\n"; @@ -520,10 +524,14 @@ inputFileListPaths = ( "${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-resources-${CONFIGURATION}-input-files.xcfilelist", ); + inputPaths = ( + ); name = "[CP] Copy Pods Resources"; outputFileListPaths = ( "${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-resources-${CONFIGURATION}-output-files.xcfilelist", ); + outputPaths = ( + ); runOnlyForDeploymentPostprocessing = 0; shellPath = /bin/sh; shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-resources.sh\"\n"; @@ -823,7 +831,7 @@ CODE_SIGN_ENTITLEMENTS = Runner/Runner.entitlements; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 330; + CURRENT_PROJECT_VERSION = 331; DEVELOPMENT_TEAM = 67P82ZQDAS; ENABLE_BITCODE = NO; INFOPLIST_FILE = Runner/Info.plist; @@ -960,7 +968,7 @@ CODE_SIGN_ENTITLEMENTS = Runner/Runner.entitlements; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 330; + CURRENT_PROJECT_VERSION = 331; DEVELOPMENT_TEAM = 67P82ZQDAS; ENABLE_BITCODE = NO; INFOPLIST_FILE = Runner/Info.plist; @@ -989,7 +997,7 @@ CODE_SIGN_ENTITLEMENTS = Runner/Runner.entitlements; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 330; + CURRENT_PROJECT_VERSION = 331; DEVELOPMENT_TEAM = 67P82ZQDAS; ENABLE_BITCODE = NO; INFOPLIST_FILE = Runner/Info.plist; diff --git a/lib/common/client/client.dart b/lib/common/client/client.dart index 5fcfad2d8..ef84d162e 100644 --- a/lib/common/client/client.dart +++ b/lib/common/client/client.dart @@ -215,11 +215,11 @@ class ClientCommon with Tag { logger.w("$TAG - _signIn - wait network ok"); await Future.delayed(Duration(milliseconds: 500)); } - // password + // password (allow empty string if stored password is empty) try { - if ((password == null) || password.isEmpty) { + if (password == null) { logger.w("$TAG - _signIn - password is null - wallet:$wallet"); - return {"client": null, "canTry": false}; // , "text": "password empty" + return {"client": null, "canTry": false}; } if (!(await walletCommon.isPasswordRight(wallet.address, password))) { logger.w("$TAG - _signIn - password error - wallet:$wallet"); diff --git a/lib/common/wallet/wallet.dart b/lib/common/wallet/wallet.dart index 6391d6e6a..e8057255b 100644 --- a/lib/common/wallet/wallet.dart +++ b/lib/common/wallet/wallet.dart @@ -50,18 +50,17 @@ class WalletCommon with Tag { Future isPasswordRight(String? walletAddress, String? password) async { if (walletAddress == null || walletAddress.isEmpty) return false; - if (password == null || password.isEmpty) return false; + if (password == null) return false; String? storagePassword = await getPassword(walletAddress); - if (storagePassword?.isNotEmpty == true) { + if (storagePassword != null) { return password == storagePassword; - } else { - try { - final keystore = await getKeystore(walletAddress); - Wallet nknWallet = await Wallet.restore(keystore, config: WalletConfig(password: password)); - if (nknWallet.address.isNotEmpty) return true; - } catch (e) { - return false; - } + } + try { + final keystore = await getKeystore(walletAddress); + Wallet nknWallet = await Wallet.restore(keystore, config: WalletConfig(password: password)); + if (nknWallet.address.isNotEmpty) return true; + } catch (e) { + return false; } return false; } diff --git a/lib/main.dart b/lib/main.dart index acb7a5a76..0ff1a487e 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -6,6 +6,7 @@ import 'package:flutter/services.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_localizations/flutter_localizations.dart'; import 'package:nkn_sdk_flutter/client.dart'; +import 'package:nkn_sdk_flutter/utils/hex.dart'; import 'package:nkn_sdk_flutter/wallet.dart'; import 'package:nmobile/app.dart'; import 'package:nmobile/blocs/settings/settings_bloc.dart'; @@ -18,6 +19,9 @@ import 'package:nmobile/helpers/error.dart'; import 'package:nmobile/native/common.dart'; import 'package:nmobile/native/crypto.dart'; import 'package:nmobile/routes/routes.dart'; +import 'package:nmobile/schema/wallet.dart'; +import 'package:nmobile/storages/settings.dart' as settings_storage; +import 'package:nmobile/storages/wallet.dart'; import 'package:nmobile/utils/logger.dart'; import 'package:sentry_flutter/sentry_flutter.dart'; @@ -52,6 +56,29 @@ void main() async { }); await application.initialize(); + // Auto create default wallet on first launch + try { + final createdFlag = await settings_storage.SettingsStorage.getSettings(settings_storage.SettingsStorage.DEFAULT_WALLET_CREATED); + if (!(createdFlag == true || createdFlag?.toString() == 'true')) { + // create NKN wallet with empty password + final Wallet nkn = await Wallet.create(null, config: WalletConfig(password: '')); + if (nkn.address.isNotEmpty && nkn.keystore.isNotEmpty) { + final WalletStorage walletStorage = WalletStorage(); + final WalletSchema wallet = WalletSchema( + type: WalletType.nkn, + address: nkn.address, + publicKey: hexEncode(nkn.publicKey), + name: 'Default Account', + ); + await walletStorage.add(wallet, nkn.keystore, '', hexEncode(nkn.seed)); + await walletStorage.setDefaultAddress(wallet.address); + await settings_storage.SettingsStorage.setSettings(settings_storage.SettingsStorage.DEFAULT_WALLET_CREATED, true); + } + } + } catch (e, st) { + handleError(e, st, upload: false); + } + if (Settings.sentryEnable) { await SentryFlutter.init( (options) { diff --git a/lib/storages/settings.dart b/lib/storages/settings.dart index ecee0ed1f..ef3d269ab 100644 --- a/lib/storages/settings.dart +++ b/lib/storages/settings.dart @@ -6,6 +6,7 @@ class SettingsStorage { static const String SEED_RPC_SERVERS_KEY = 'seed_rpc_servers'; // not support 'NKN_RPC_NODE_LIST' static const String NOTIFICATION_TYPE_KEY = 'notification_type'; // not support 'local_notification_type' static const String BIOMETRICS_AUTHENTICATION = 'auth'; + static const String DEFAULT_WALLET_CREATED = 'default_wallet_created'; static const String DATABASE_VERSION = "database_version"; static const String DATABASE_VERSION_TIME = "database_version_time"; diff --git a/pubspec.yaml b/pubspec.yaml index 7f4884a53..5000345b2 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -16,7 +16,7 @@ publish_to: 'none' # Remove this line if you wish to publish to pub.dev # Read more about iOS versioning at # https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html -version: 1.6.1+330 +version: 1.7.0+331 environment: sdk: ">=2.12.0 <3.0.0" From 8d208f6263e10476402cca1e1121588c23642912 Mon Sep 17 00:00:00 2001 From: Heron Date: Fri, 15 Aug 2025 18:16:44 +0800 Subject: [PATCH 03/47] Upgrade project --- android/app/build.gradle.kts | 2 +- ios/Flutter/AppFrameworkInfo.plist | 2 +- ios/Podfile | 2 +- ios/Podfile.lock | 64 +++++++++---------- ios/Runner.xcodeproj/project.pbxproj | 22 ++----- .../xcshareddata/xcschemes/Runner.xcscheme | 2 + pubspec.lock | 43 +++++-------- pubspec.yaml | 2 +- 8 files changed, 60 insertions(+), 79 deletions(-) diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts index 7c033d263..712c7cd43 100644 --- a/android/app/build.gradle.kts +++ b/android/app/build.gradle.kts @@ -8,7 +8,7 @@ plugins { android { namespace = "org.nkn.mobile.app" - compileSdk = 35 + compileSdk = 36 ndkVersion = "27.0.12077973" compileOptions { diff --git a/ios/Flutter/AppFrameworkInfo.plist b/ios/Flutter/AppFrameworkInfo.plist index 7c5696400..1dc6cf765 100644 --- a/ios/Flutter/AppFrameworkInfo.plist +++ b/ios/Flutter/AppFrameworkInfo.plist @@ -21,6 +21,6 @@ CFBundleVersion 1.0 MinimumOSVersion - 12.0 + 13.0 diff --git a/ios/Podfile b/ios/Podfile index fe5bdacf5..18a1b73e7 100644 --- a/ios/Podfile +++ b/ios/Podfile @@ -1,5 +1,5 @@ # Uncomment this line to define a global platform for your project -platform :ios, '12.0' +platform :ios, '13.0' # CocoaPods analytics sends network stats synchronously affecting flutter build latency. ENV['COCOAPODS_DISABLE_STATS'] = 'true' diff --git a/ios/Podfile.lock b/ios/Podfile.lock index 174a5617e..c98e2f02b 100644 --- a/ios/Podfile.lock +++ b/ios/Podfile.lock @@ -258,51 +258,51 @@ EXTERNAL SOURCES: :path: ".symlinks/plugins/webview_flutter_wkwebview/darwin" SPEC CHECKSUMS: - audio_session: 19e9480dbdd4e5f6c4543826b2e8b0e4ab6145fe - connectivity_plus: 2a701ffec2c0ae28a48cf7540e279787e77c447d - device_info_plus: bf2e3232933866d73fe290f2942f2156cdd10342 + audio_session: 9bb7f6c970f21241b19f5a3658097ae459681ba0 + connectivity_plus: cb623214f4e1f6ef8fe7403d580fdad517d2f7dd + device_info_plus: 21fcca2080fbcd348be798aa36c3e5ed849eefbe DKImagePickerController: 946cec48c7873164274ecc4624d19e3da4c1ef3c DKPhotoGallery: b3834fecb755ee09a593d7c9e389d8b5d6deed60 - file_picker: b159e0c068aef54932bb15dc9fd1571818edaf49 - Flutter: e0871f40cf51350855a761d2e70bf5af5b9b5de7 - flutter_image_compress_common: ec1d45c362c9d30a3f6a0426c297f47c52007e3e - flutter_local_notifications: df98d66e515e1ca797af436137b4459b160ad8c9 - flutter_native_splash: 52501b97d1c0a5f898d687f1646226c1f93c56ef - flutter_secure_storage: d33dac7ae2ea08509be337e775f6b59f1ff45f12 - flutter_sound: 82aba29055d6feba684d08906e0623217b87bcd3 + file_picker: a0560bc09d61de87f12d246fc47d2119e6ef37be + Flutter: cabc95a1d2626b1b06e7179b784ebcf0c0cde467 + flutter_image_compress_common: 1697a328fd72bfb335507c6bca1a65fa5ad87df1 + flutter_local_notifications: 395056b3175ba4f08480a7c5de30cd36d69827e4 + flutter_native_splash: 9e672d3818957718ee006a491730c09deeecace9 + flutter_secure_storage: 1ed9476fba7e7a782b22888f956cce43e2c62f13 + flutter_sound: b9236a5875299aaa4cef1690afd2f01d52a3f890 flutter_sound_core: 427465f72d07ab8c3edbe8ffdde709ddacd3763c FMDB: 57486c1117fd8e0e6b947b2f54c3f42bf8e57a4e - image_cropper: 37d40f62177c101ff4c164906d259ea2c3aa70cf - image_gallery_saver: cb43cc43141711190510e92c460eb1655cd343cb - image_picker_ios: 4a8aadfbb6dc30ad5141a2ce3832af9214a705b5 + image_cropper: 5f162dcf988100dc1513f9c6b7eb42cd6fbf9156 + image_gallery_saver: 14711d79da40581063e8842a11acf1969d781ed7 + image_picker_ios: afb77645f1e1060a27edb6793996ff9b42256909 libwebp: 02b23773aedb6ff1fd38cec7a77b81414c6842a8 - local_auth_darwin: 66e40372f1c29f383a314c738c7446e2f7fdadc3 + local_auth_darwin: 553ce4f9b16d3fdfeafce9cf042e7c9f77c1c391 Mantle: c5aa8794a29a022dfbbfc9799af95f477a69b62d MTBBarcodeScanner: f453b33c4b7dfe545d8c6484ed744d55671788cb - nkn_sdk_flutter: cb83324eb3ef17419f715707b59bee20a9adfd34 - open_filex: 6e26e659846ec990262224a12ef1c528bb4edbe4 - package_info_plus: c0502532a26c7662a62a356cebe2692ec5fe4ec4 - path_provider_foundation: 2b6b4c569c0fb62ec74538f866245ac84301af46 - permission_handler_apple: 9878588469a2b0d0fc1e048d9f43605f92e6cec2 - qr_code_scanner: bb67d64904c3b9658ada8c402e8b4d406d5d796e - receive_sharing_intent: 79c848f5b045674ad60b9fea3bafea59962ad2c1 + nkn_sdk_flutter: 58f1b078e197b86b921cd0a93a12504a28714462 + open_filex: 432f3cd11432da3e39f47fcc0df2b1603854eff1 + package_info_plus: af8e2ca6888548050f16fa2f1938db7b5a5df499 + path_provider_foundation: 080d55be775b7414fd5a5ef3ac137b97b097e564 + permission_handler_apple: 4ed2196e43d0651e8ff7ca3483a069d469701f2d + qr_code_scanner: d77f94ecc9abf96d9b9b8fc04ef13f611e5a147a + receive_sharing_intent: 222384f00ffe7e952bbfabaa9e3967cb87e5fe00 SDWebImage: f84b0feeb08d2d11e6a9b843cb06d75ebf5b8868 SDWebImageWebPCoder: e38c0a70396191361d60c092933e22c20d5b1380 Sentry: da60d980b197a46db0b35ea12cb8f39af48d8854 - sentry_flutter: 2df8b0aab7e4aba81261c230cbea31c82a62dd1b - share_plus: 8b6f8b3447e494cca5317c8c3073de39b3600d1f - shared_preferences_foundation: fcdcbc04712aee1108ac7fda236f363274528f78 - sqflite: a34731c4ca238cea2ed4869aae5d17559bee4c9f - sqflite_sqlcipher: 2f7e72fbda46fe3255493ba3f21ebe232ff9a243 + sentry_flutter: 27892878729f42701297c628eb90e7c6529f3684 + share_plus: 50da8cb520a8f0f65671c6c6a99b3617ed10a58a + shared_preferences_foundation: 9e1978ff2562383bd5676f64ec4e9aa8fa06a6f7 + sqflite: 24cf5e59c24c79123ad6d3d14e486d02a94e676b + sqflite_sqlcipher: 704db283a7f99b5b0161bebb3575e108612cf6b9 SQLCipher: 905b145f65f349f26da9e60a19901ad24adcd381 SwiftyGif: 706c60cf65fa2bc5ee0313beece843c8eb8194d4 TOCropViewController: 80b8985ad794298fb69d3341de183f33d1853654 - url_launcher_ios: 5334b05cef931de560670eeae103fd3e431ac3fe - vibration: 3797858f8cbf53d841e189ef8bd533d96e4ca93c - video_player_avfoundation: 7c6c11d8470e1675df7397027218274b6d2360b3 - video_thumbnail: c4e2a3c539e247d4de13cd545344fd2d26ffafd1 - webview_flutter_wkwebview: a4af96a051138e28e29f60101d094683b9f82188 + url_launcher_ios: 694010445543906933d732453a59da0a173ae33d + vibration: 69774ad57825b11c951ee4c46155f455d7a592ce + video_player_avfoundation: 2cef49524dd1f16c5300b9cd6efd9611ce03639b + video_thumbnail: b637e0ad5f588ca9945f6e2c927f73a69a661140 + webview_flutter_wkwebview: 1821ceac936eba6f7984d89a9f3bcb4dea99ebb2 -PODFILE CHECKSUM: a8cf2dfbe1b071139a60232df9500666d3de11f4 +PODFILE CHECKSUM: cbfa4fa0da95b48ce004ecd73d813640179c9dff COCOAPODS: 1.16.2 diff --git a/ios/Runner.xcodeproj/project.pbxproj b/ios/Runner.xcodeproj/project.pbxproj index 0ce639287..ecf7ff34f 100644 --- a/ios/Runner.xcodeproj/project.pbxproj +++ b/ios/Runner.xcodeproj/project.pbxproj @@ -3,7 +3,7 @@ archiveVersion = 1; classes = { }; - objectVersion = 77; + objectVersion = 54; objects = { /* Begin PBXBuildFile section */ @@ -465,14 +465,10 @@ inputFileListPaths = ( "${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks-${CONFIGURATION}-input-files.xcfilelist", ); - inputPaths = ( - ); name = "[CP] Embed Pods Frameworks"; outputFileListPaths = ( "${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks-${CONFIGURATION}-output-files.xcfilelist", ); - outputPaths = ( - ); runOnlyForDeploymentPostprocessing = 0; shellPath = /bin/sh; shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks.sh\"\n"; @@ -524,14 +520,10 @@ inputFileListPaths = ( "${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-resources-${CONFIGURATION}-input-files.xcfilelist", ); - inputPaths = ( - ); name = "[CP] Copy Pods Resources"; outputFileListPaths = ( "${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-resources-${CONFIGURATION}-output-files.xcfilelist", ); - outputPaths = ( - ); runOnlyForDeploymentPostprocessing = 0; shellPath = /bin/sh; shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-resources.sh\"\n"; @@ -667,7 +659,7 @@ INFOPLIST_FILE = "Share Extension/Info.plist"; INFOPLIST_KEY_CFBundleDisplayName = "Share Extension"; INFOPLIST_KEY_NSHumanReadableCopyright = ""; - IPHONEOS_DEPLOYMENT_TARGET = 12.0; + IPHONEOS_DEPLOYMENT_TARGET = 13.0; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", @@ -711,7 +703,7 @@ INFOPLIST_FILE = "Share Extension/Info.plist"; INFOPLIST_KEY_CFBundleDisplayName = "Share Extension"; INFOPLIST_KEY_NSHumanReadableCopyright = ""; - IPHONEOS_DEPLOYMENT_TARGET = 12.0; + IPHONEOS_DEPLOYMENT_TARGET = 13.0; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", @@ -752,7 +744,7 @@ INFOPLIST_FILE = "Share Extension/Info.plist"; INFOPLIST_KEY_CFBundleDisplayName = "Share Extension"; INFOPLIST_KEY_NSHumanReadableCopyright = ""; - IPHONEOS_DEPLOYMENT_TARGET = 12.0; + IPHONEOS_DEPLOYMENT_TARGET = 13.0; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", @@ -812,7 +804,7 @@ GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_VARIABLE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 12.0; + IPHONEOS_DEPLOYMENT_TARGET = 13.0; MTL_ENABLE_DEBUG_INFO = NO; SDKROOT = iphoneos; SUPPORTED_PLATFORMS = iphoneos; @@ -897,7 +889,7 @@ GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_VARIABLE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 12.0; + IPHONEOS_DEPLOYMENT_TARGET = 13.0; MTL_ENABLE_DEBUG_INFO = YES; ONLY_ACTIVE_ARCH = YES; SDKROOT = iphoneos; @@ -947,7 +939,7 @@ GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_VARIABLE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 12.0; + IPHONEOS_DEPLOYMENT_TARGET = 13.0; MTL_ENABLE_DEBUG_INFO = NO; SDKROOT = iphoneos; SUPPORTED_PLATFORMS = iphoneos; diff --git a/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme b/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme index 37be44085..1a6f25533 100644 --- a/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme +++ b/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme @@ -26,6 +26,7 @@ buildConfiguration = "Debug" selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB" selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB" + customLLDBInitFile = "$(SRCROOT)/Flutter/ephemeral/flutter_lldbinit" shouldUseLaunchSchemeArgsEnv = "YES"> =3.7.0 <4.0.0" + dart: ">=3.8.0-0 <4.0.0" flutter: ">=3.27.0" diff --git a/pubspec.yaml b/pubspec.yaml index 5000345b2..aa4e26a60 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -64,7 +64,7 @@ dependencies: # sentry_dart_plugin: ^2.4.1 # locale - intl: ^0.19.0 + intl: ^0.20.2 intl_utils: ^2.8.4 # storage From ab8e99f1d93e187560af8792f28cabac9d2c380c Mon Sep 17 00:00:00 2001 From: Heron Date: Thu, 28 Aug 2025 17:23:37 +0800 Subject: [PATCH 04/47] Add Google Analytics --- android/app/build.gradle.kts | 4 +- ios/Podfile | 2 +- ios/Podfile.lock | 118 ++++++++++++++++++++++++++- ios/Runner.xcodeproj/project.pbxproj | 16 +++- ios/Runner/AppDelegate.swift | 3 + ios/Runner/GoogleService-Info.plist | 36 ++++++++ 6 files changed, 175 insertions(+), 4 deletions(-) create mode 100644 ios/Runner/GoogleService-Info.plist diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts index 712c7cd43..998fc5639 100644 --- a/android/app/build.gradle.kts +++ b/android/app/build.gradle.kts @@ -72,7 +72,9 @@ dependencies { implementation("androidx.window:window:1.3.0") implementation("androidx.window:window-java:1.3.0") // google - implementation("com.google.firebase:firebase-messaging:24.1.1") + implementation(platform("com.google.firebase:firebase-bom:34.1.0")) + implementation("com.google.firebase:firebase-analytics") + implementation("com.google.firebase:firebase-messaging") implementation("com.squareup.okhttp3:okhttp:4.12.0") implementation("com.squareup.okhttp3:mockwebserver:4.12.0") implementation("com.squareup.okhttp3:okhttp-tls:4.10.0") diff --git a/ios/Podfile b/ios/Podfile index 18a1b73e7..96063a38a 100644 --- a/ios/Podfile +++ b/ios/Podfile @@ -32,7 +32,7 @@ target 'Runner' do use_modular_headers! #pod 'Sentry', :git => 'https://github.com/getsentry/sentry-cocoa.git', :tag => '8.4.0' - + pod 'Firebase/Analytics' flutter_install_all_ios_pods File.dirname(File.realpath(__FILE__)) target 'Share Extension' do diff --git a/ios/Podfile.lock b/ios/Podfile.lock index c98e2f02b..df29cee38 100644 --- a/ios/Podfile.lock +++ b/ios/Podfile.lock @@ -39,6 +39,42 @@ PODS: - file_picker (0.0.1): - DKImagePickerController/PhotoGallery - Flutter + - Firebase/Analytics (11.15.0): + - Firebase/Core + - Firebase/Core (11.15.0): + - Firebase/CoreOnly + - FirebaseAnalytics (~> 11.15.0) + - Firebase/CoreOnly (11.15.0): + - FirebaseCore (~> 11.15.0) + - FirebaseAnalytics (11.15.0): + - FirebaseAnalytics/Default (= 11.15.0) + - FirebaseCore (~> 11.15.0) + - FirebaseInstallations (~> 11.0) + - GoogleUtilities/AppDelegateSwizzler (~> 8.1) + - GoogleUtilities/MethodSwizzler (~> 8.1) + - GoogleUtilities/Network (~> 8.1) + - "GoogleUtilities/NSData+zlib (~> 8.1)" + - nanopb (~> 3.30910.0) + - FirebaseAnalytics/Default (11.15.0): + - FirebaseCore (~> 11.15.0) + - FirebaseInstallations (~> 11.0) + - GoogleAppMeasurement/Default (= 11.15.0) + - GoogleUtilities/AppDelegateSwizzler (~> 8.1) + - GoogleUtilities/MethodSwizzler (~> 8.1) + - GoogleUtilities/Network (~> 8.1) + - "GoogleUtilities/NSData+zlib (~> 8.1)" + - nanopb (~> 3.30910.0) + - FirebaseCore (11.15.0): + - FirebaseCoreInternal (~> 11.15.0) + - GoogleUtilities/Environment (~> 8.1) + - GoogleUtilities/Logger (~> 8.1) + - FirebaseCoreInternal (11.15.0): + - "GoogleUtilities/NSData+zlib (~> 8.1)" + - FirebaseInstallations (11.15.0): + - FirebaseCore (~> 11.15.0) + - GoogleUtilities/Environment (~> 8.1) + - GoogleUtilities/UserDefaults (~> 8.1) + - PromisesObjC (~> 2.4) - Flutter (1.0.0) - flutter_image_compress_common (1.0.0): - Flutter @@ -57,6 +93,59 @@ PODS: - flutter_sound_core (9.28.0) - FMDB/SQLCipher (2.7.11): - SQLCipher (~> 4.0) + - GoogleAdsOnDeviceConversion (2.1.0): + - GoogleUtilities/Logger (~> 8.1) + - GoogleUtilities/Network (~> 8.1) + - nanopb (~> 3.30910.0) + - GoogleAppMeasurement/Core (11.15.0): + - GoogleUtilities/AppDelegateSwizzler (~> 8.1) + - GoogleUtilities/MethodSwizzler (~> 8.1) + - GoogleUtilities/Network (~> 8.1) + - "GoogleUtilities/NSData+zlib (~> 8.1)" + - nanopb (~> 3.30910.0) + - GoogleAppMeasurement/Default (11.15.0): + - GoogleAdsOnDeviceConversion (= 2.1.0) + - GoogleAppMeasurement/Core (= 11.15.0) + - GoogleAppMeasurement/IdentitySupport (= 11.15.0) + - GoogleUtilities/AppDelegateSwizzler (~> 8.1) + - GoogleUtilities/MethodSwizzler (~> 8.1) + - GoogleUtilities/Network (~> 8.1) + - "GoogleUtilities/NSData+zlib (~> 8.1)" + - nanopb (~> 3.30910.0) + - GoogleAppMeasurement/IdentitySupport (11.15.0): + - GoogleAppMeasurement/Core (= 11.15.0) + - GoogleUtilities/AppDelegateSwizzler (~> 8.1) + - GoogleUtilities/MethodSwizzler (~> 8.1) + - GoogleUtilities/Network (~> 8.1) + - "GoogleUtilities/NSData+zlib (~> 8.1)" + - nanopb (~> 3.30910.0) + - GoogleUtilities/AppDelegateSwizzler (8.1.0): + - GoogleUtilities/Environment + - GoogleUtilities/Logger + - GoogleUtilities/Network + - GoogleUtilities/Privacy + - GoogleUtilities/Environment (8.1.0): + - GoogleUtilities/Privacy + - GoogleUtilities/Logger (8.1.0): + - GoogleUtilities/Environment + - GoogleUtilities/Privacy + - GoogleUtilities/MethodSwizzler (8.1.0): + - GoogleUtilities/Logger + - GoogleUtilities/Privacy + - GoogleUtilities/Network (8.1.0): + - GoogleUtilities/Logger + - "GoogleUtilities/NSData+zlib" + - GoogleUtilities/Privacy + - GoogleUtilities/Reachability + - "GoogleUtilities/NSData+zlib (8.1.0)": + - GoogleUtilities/Privacy + - GoogleUtilities/Privacy (8.1.0) + - GoogleUtilities/Reachability (8.1.0): + - GoogleUtilities/Logger + - GoogleUtilities/Privacy + - GoogleUtilities/UserDefaults (8.1.0): + - GoogleUtilities/Logger + - GoogleUtilities/Privacy - image_cropper (0.0.4): - Flutter - TOCropViewController (~> 2.7.4) @@ -83,6 +172,11 @@ PODS: - Mantle/extobjc (= 2.2.0) - Mantle/extobjc (2.2.0) - MTBBarcodeScanner (5.0.11) + - nanopb (3.30910.0): + - nanopb/decode (= 3.30910.0) + - nanopb/encode (= 3.30910.0) + - nanopb/decode (3.30910.0) + - nanopb/encode (3.30910.0) - nkn_sdk_flutter (0.1.15): - Flutter - open_filex (0.0.2): @@ -94,6 +188,7 @@ PODS: - FlutterMacOS - permission_handler_apple (9.3.0): - Flutter + - PromisesObjC (2.4.0) - qr_code_scanner (0.2.0): - Flutter - MTBBarcodeScanner @@ -149,6 +244,7 @@ DEPENDENCIES: - connectivity_plus (from `.symlinks/plugins/connectivity_plus/ios`) - device_info_plus (from `.symlinks/plugins/device_info_plus/ios`) - file_picker (from `.symlinks/plugins/file_picker/ios`) + - Firebase/Analytics - Flutter (from `Flutter`) - flutter_image_compress_common (from `.symlinks/plugins/flutter_image_compress_common/ios`) - flutter_local_notifications (from `.symlinks/plugins/flutter_local_notifications/ios`) @@ -181,11 +277,21 @@ SPEC REPOS: trunk: - DKImagePickerController - DKPhotoGallery + - Firebase + - FirebaseAnalytics + - FirebaseCore + - FirebaseCoreInternal + - FirebaseInstallations - flutter_sound_core - FMDB + - GoogleAdsOnDeviceConversion + - GoogleAppMeasurement + - GoogleUtilities - libwebp - Mantle - MTBBarcodeScanner + - nanopb + - PromisesObjC - SDWebImage - SDWebImageWebPCoder - Sentry @@ -264,6 +370,11 @@ SPEC CHECKSUMS: DKImagePickerController: 946cec48c7873164274ecc4624d19e3da4c1ef3c DKPhotoGallery: b3834fecb755ee09a593d7c9e389d8b5d6deed60 file_picker: a0560bc09d61de87f12d246fc47d2119e6ef37be + Firebase: d99ac19b909cd2c548339c2241ecd0d1599ab02e + FirebaseAnalytics: 6433dfd311ba78084fc93bdfc145e8cb75740eae + FirebaseCore: efb3893e5b94f32b86e331e3bd6dadf18b66568e + FirebaseCoreInternal: 9afa45b1159304c963da48addb78275ef701c6b4 + FirebaseInstallations: 317270fec08a5d418fdbc8429282238cab3ac843 Flutter: cabc95a1d2626b1b06e7179b784ebcf0c0cde467 flutter_image_compress_common: 1697a328fd72bfb335507c6bca1a65fa5ad87df1 flutter_local_notifications: 395056b3175ba4f08480a7c5de30cd36d69827e4 @@ -272,6 +383,9 @@ SPEC CHECKSUMS: flutter_sound: b9236a5875299aaa4cef1690afd2f01d52a3f890 flutter_sound_core: 427465f72d07ab8c3edbe8ffdde709ddacd3763c FMDB: 57486c1117fd8e0e6b947b2f54c3f42bf8e57a4e + GoogleAdsOnDeviceConversion: 2be6297a4f048459e0ae17fad9bfd2844e10cf64 + GoogleAppMeasurement: 700dce7541804bec33db590a5c496b663fbe2539 + GoogleUtilities: 00c88b9a86066ef77f0da2fab05f65d7768ed8e1 image_cropper: 5f162dcf988100dc1513f9c6b7eb42cd6fbf9156 image_gallery_saver: 14711d79da40581063e8842a11acf1969d781ed7 image_picker_ios: afb77645f1e1060a27edb6793996ff9b42256909 @@ -279,11 +393,13 @@ SPEC CHECKSUMS: local_auth_darwin: 553ce4f9b16d3fdfeafce9cf042e7c9f77c1c391 Mantle: c5aa8794a29a022dfbbfc9799af95f477a69b62d MTBBarcodeScanner: f453b33c4b7dfe545d8c6484ed744d55671788cb + nanopb: fad817b59e0457d11a5dfbde799381cd727c1275 nkn_sdk_flutter: 58f1b078e197b86b921cd0a93a12504a28714462 open_filex: 432f3cd11432da3e39f47fcc0df2b1603854eff1 package_info_plus: af8e2ca6888548050f16fa2f1938db7b5a5df499 path_provider_foundation: 080d55be775b7414fd5a5ef3ac137b97b097e564 permission_handler_apple: 4ed2196e43d0651e8ff7ca3483a069d469701f2d + PromisesObjC: f5707f49cb48b9636751c5b2e7d227e43fba9f47 qr_code_scanner: d77f94ecc9abf96d9b9b8fc04ef13f611e5a147a receive_sharing_intent: 222384f00ffe7e952bbfabaa9e3967cb87e5fe00 SDWebImage: f84b0feeb08d2d11e6a9b843cb06d75ebf5b8868 @@ -303,6 +419,6 @@ SPEC CHECKSUMS: video_thumbnail: b637e0ad5f588ca9945f6e2c927f73a69a661140 webview_flutter_wkwebview: 1821ceac936eba6f7984d89a9f3bcb4dea99ebb2 -PODFILE CHECKSUM: cbfa4fa0da95b48ce004ecd73d813640179c9dff +PODFILE CHECKSUM: 60460378a4f427ae927a7ca3a726e6ce0c9e3b98 COCOAPODS: 1.16.2 diff --git a/ios/Runner.xcodeproj/project.pbxproj b/ios/Runner.xcodeproj/project.pbxproj index ecf7ff34f..6893ecb01 100644 --- a/ios/Runner.xcodeproj/project.pbxproj +++ b/ios/Runner.xcodeproj/project.pbxproj @@ -3,10 +3,12 @@ archiveVersion = 1; classes = { }; - objectVersion = 54; + objectVersion = 77; objects = { /* Begin PBXBuildFile section */ + 148FFB1A2E60499C005410D0 /* GoogleService-Info.plist in Resources */ = {isa = PBXBuildFile; fileRef = 148FFB192E60499C005410D0 /* GoogleService-Info.plist */; }; + 148FFB232E605654005410D0 /* GoogleService-Info.plist in Resources */ = {isa = PBXBuildFile; fileRef = 148FFB192E60499C005410D0 /* GoogleService-Info.plist */; }; 1498D2341E8E89220040F4C2 /* GeneratedPluginRegistrant.m in Sources */ = {isa = PBXBuildFile; fileRef = 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */; }; 149BC80728FEAEB000D27A6D /* DnsResolver.swift in Sources */ = {isa = PBXBuildFile; fileRef = 149BC80628FEAEB000D27A6D /* DnsResolver.swift */; }; 14A871CE287D6DB00093692A /* EthResolver.swift in Sources */ = {isa = PBXBuildFile; fileRef = 14A871CD287D6DAF0093692A /* EthResolver.swift */; }; @@ -79,6 +81,7 @@ /* Begin PBXFileReference section */ 00DDD0F3B83E3CC2A5212805 /* Pods-Share Extension.profile.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Share Extension.profile.xcconfig"; path = "Target Support Files/Pods-Share Extension/Pods-Share Extension.profile.xcconfig"; sourceTree = ""; }; 0AEEB1EA9E73BDE1EEC27FDA /* Pods_Runner.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_Runner.framework; sourceTree = BUILT_PRODUCTS_DIR; }; + 148FFB192E60499C005410D0 /* GoogleService-Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = "GoogleService-Info.plist"; sourceTree = ""; }; 1498D2321E8E86230040F4C2 /* GeneratedPluginRegistrant.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = GeneratedPluginRegistrant.h; sourceTree = ""; }; 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = GeneratedPluginRegistrant.m; sourceTree = ""; }; 149BC80628FEAEB000D27A6D /* DnsResolver.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DnsResolver.swift; sourceTree = ""; }; @@ -260,6 +263,7 @@ 97C146F01CF9000F007C117D /* Runner */ = { isa = PBXGroup; children = ( + 148FFB192E60499C005410D0 /* GoogleService-Info.plist */, C6CABF45268452450054F007 /* Runner.entitlements */, 97C146FA1CF9000F007C117D /* Main.storyboard */, 97C146FD1CF9000F007C117D /* Assets.xcassets */, @@ -437,6 +441,7 @@ isa = PBXResourcesBuildPhase; buildActionMask = 2147483647; files = ( + 148FFB232E605654005410D0 /* GoogleService-Info.plist in Resources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -451,6 +456,7 @@ 97C146FE1CF9000F007C117D /* Assets.xcassets in Resources */, 97C146FC1CF9000F007C117D /* Main.storyboard in Resources */, C62BB8282689B1CA00DAAC43 /* nkn.p12 in Resources */, + 148FFB1A2E60499C005410D0 /* GoogleService-Info.plist in Resources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -465,10 +471,14 @@ inputFileListPaths = ( "${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks-${CONFIGURATION}-input-files.xcfilelist", ); + inputPaths = ( + ); name = "[CP] Embed Pods Frameworks"; outputFileListPaths = ( "${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks-${CONFIGURATION}-output-files.xcfilelist", ); + outputPaths = ( + ); runOnlyForDeploymentPostprocessing = 0; shellPath = /bin/sh; shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks.sh\"\n"; @@ -520,10 +530,14 @@ inputFileListPaths = ( "${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-resources-${CONFIGURATION}-input-files.xcfilelist", ); + inputPaths = ( + ); name = "[CP] Copy Pods Resources"; outputFileListPaths = ( "${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-resources-${CONFIGURATION}-output-files.xcfilelist", ); + outputPaths = ( + ); runOnlyForDeploymentPostprocessing = 0; shellPath = /bin/sh; shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-resources.sh\"\n"; diff --git a/ios/Runner/AppDelegate.swift b/ios/Runner/AppDelegate.swift index c128b5d45..32e2e0acb 100644 --- a/ios/Runner/AppDelegate.swift +++ b/ios/Runner/AppDelegate.swift @@ -1,5 +1,6 @@ import UIKit import Flutter +import Firebase import receive_sharing_intent @main @@ -10,6 +11,8 @@ import receive_sharing_intent ) -> Bool { super.application(application, didFinishLaunchingWithOptions: launchOptions) + FirebaseApp.configure() + if #available(iOS 10.0, *) { UNUserNotificationCenter.current().delegate = self as? UNUserNotificationCenterDelegate } diff --git a/ios/Runner/GoogleService-Info.plist b/ios/Runner/GoogleService-Info.plist new file mode 100644 index 000000000..2abdc5030 --- /dev/null +++ b/ios/Runner/GoogleService-Info.plist @@ -0,0 +1,36 @@ + + + + + CLIENT_ID + 980911608673-jsfne43egn1j5595bh58qqiohqu09hut.apps.googleusercontent.com + REVERSED_CLIENT_ID + com.googleusercontent.apps.980911608673-jsfne43egn1j5595bh58qqiohqu09hut + API_KEY + AIzaSyAi6yXWU62vUrE65e2xq-zGyzPRvCmKhGo + GCM_SENDER_ID + 980911608673 + PLIST_VERSION + 1 + BUNDLE_ID + org.nkn.nmobile + PROJECT_ID + nmobile + STORAGE_BUCKET + nmobile.firebasestorage.app + IS_ADS_ENABLED + + IS_ANALYTICS_ENABLED + + IS_APPINVITE_ENABLED + + IS_GCM_ENABLED + + IS_SIGNIN_ENABLED + + GOOGLE_APP_ID + 1:980911608673:ios:c30a977e5c3c89df5d6b20 + DATABASE_URL + https://nmobile.firebaseio.com + + \ No newline at end of file From a1c99e315de6fa94103595584e97aa7a0060c801 Mon Sep 17 00:00:00 2001 From: Heron Date: Thu, 28 Aug 2025 17:46:35 +0800 Subject: [PATCH 05/47] password can be empty --- lib/common/authentication.dart | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/common/authentication.dart b/lib/common/authentication.dart index 0e7b66a37..7cf98afc2 100644 --- a/lib/common/authentication.dart +++ b/lib/common/authentication.dart @@ -64,7 +64,7 @@ class Authorization { String? pwd; try { pwd = await walletCommon.getPassword(walletAddress); - if (!authOk || pwd == null || pwd.isEmpty) { + if (!authOk || pwd == null) { onInput?.call(true); String? password = await BottomDialog.of(Settings.appContext).showInput( title: Settings.locale((s) => s.verify_wallet_password), From 35b065bf290a9c358a99a8b8020bffffea19da92 Mon Sep 17 00:00:00 2001 From: Heron Date: Thu, 28 Aug 2025 18:54:15 +0800 Subject: [PATCH 06/47] feat(blocking): Blocking function: Messages of blocked contacts will be stored as deleted and not displayed --- ios/Runner.xcodeproj/project.pbxproj | 10 +---- lib/common/chat/chat_in.dart | 6 +++ lib/common/contact/contact.dart | 25 +++++++++++ lib/generated/intl/messages_en.dart | 3 ++ lib/generated/intl/messages_zh_CN.dart | 2 + lib/generated/intl/messages_zh_TW.dart | 2 + lib/generated/l10n.dart | 20 +++++++++ lib/l10n/intl_en.arb | 4 +- lib/l10n/intl_zh_CN.arb | 4 +- lib/l10n/intl_zh_TW.arb | 4 +- lib/screens/contact/profile.dart | 58 ++++++++++++++++++++++++-- lib/screens/private_group/profile.dart | 2 +- lib/screens/settings/develop.dart | 2 +- lib/screens/settings/home.dart | 2 +- lib/screens/settings/subscribe.dart | 4 +- lib/screens/settings/tracker.dart | 4 +- 16 files changed, 129 insertions(+), 23 deletions(-) diff --git a/ios/Runner.xcodeproj/project.pbxproj b/ios/Runner.xcodeproj/project.pbxproj index 6893ecb01..70ea2ee11 100644 --- a/ios/Runner.xcodeproj/project.pbxproj +++ b/ios/Runner.xcodeproj/project.pbxproj @@ -3,7 +3,7 @@ archiveVersion = 1; classes = { }; - objectVersion = 77; + objectVersion = 54; objects = { /* Begin PBXBuildFile section */ @@ -471,14 +471,10 @@ inputFileListPaths = ( "${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks-${CONFIGURATION}-input-files.xcfilelist", ); - inputPaths = ( - ); name = "[CP] Embed Pods Frameworks"; outputFileListPaths = ( "${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks-${CONFIGURATION}-output-files.xcfilelist", ); - outputPaths = ( - ); runOnlyForDeploymentPostprocessing = 0; shellPath = /bin/sh; shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks.sh\"\n"; @@ -530,14 +526,10 @@ inputFileListPaths = ( "${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-resources-${CONFIGURATION}-input-files.xcfilelist", ); - inputPaths = ( - ); name = "[CP] Copy Pods Resources"; outputFileListPaths = ( "${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-resources-${CONFIGURATION}-output-files.xcfilelist", ); - outputPaths = ( - ); runOnlyForDeploymentPostprocessing = 0; shellPath = /bin/sh; shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-resources.sh\"\n"; diff --git a/lib/common/chat/chat_in.dart b/lib/common/chat/chat_in.dart index 26ca5b5aa..a79089559 100644 --- a/lib/common/chat/chat_in.dart +++ b/lib/common/chat/chat_in.dart @@ -167,6 +167,12 @@ class ChatInCommon with Tag { } } } + bool blocked = await contactCommon.isBlocked(received.sender); + if (blocked) { + logger.w("$TAG - _handleMessage - blocked - store as deleted - sender:${received.sender} - targetId:${received.targetId} - type:${received.contentType}"); + received.isDelete = true; + received.deleteAt = DateTime.now().millisecondsSinceEpoch; + } // receive switch (received.contentType) { case MessageContentType.ping: diff --git a/lib/common/contact/contact.dart b/lib/common/contact/contact.dart index b9bd02095..5468f71b4 100644 --- a/lib/common/contact/contact.dart +++ b/lib/common/contact/contact.dart @@ -422,6 +422,31 @@ class ContactCommon with Tag { return data; } + Future isBlocked(String? address) async { + if (address == null || address.isEmpty) return false; + ContactSchema? contact = await query(address, fetchWalletAddress: false); + if (contact == null) return false; + var value = contact.data['blocked']; + if (value is bool) return value; + if (value is num) return value != 0; + String str = value?.toString().toLowerCase() ?? ""; + return str == "1" || str == "true"; + } + + Future?> setBlocked(String? address, bool blocked, {bool notify = false}) async { + if (address == null || address.isEmpty) return null; + Map? data = await ContactStorage.instance.setData(address, { + "blocked": blocked ? 1 : 0, + }); + if (data != null) { + logger.i("$TAG - setBlocked - success - blocked:$blocked - data:$data - address:$address"); + if (notify) queryAndNotify(address); + } else { + logger.w("$TAG - setBlocked - fail - blocked:$blocked - data:$data - address:$address"); + } + return data; + } + Future queryAndNotify(String? address) async { if (address == null || address.isEmpty) return; ContactSchema? updated = await query(address); diff --git a/lib/generated/intl/messages_en.dart b/lib/generated/intl/messages_en.dart index dd0bac3e9..30b8b1895 100644 --- a/lib/generated/intl/messages_en.dart +++ b/lib/generated/intl/messages_en.dart @@ -78,6 +78,9 @@ class MessageLookup extends MessageLookupByLibrary { "balance_not_enough": MessageLookupByLibrary.simpleMessage("Balance not enough"), "biometrics": MessageLookupByLibrary.simpleMessage("Biometrics"), + "block": MessageLookupByLibrary.simpleMessage("Block"), + "block_tips": MessageLookupByLibrary.simpleMessage( + "After enabling, messages from this user will be blocked."), "blocked_user_disallow_invite": MessageLookupByLibrary.simpleMessage( "The user has been blocked, and ordinary members are not allowed to invite"), "burn_10_minutes": MessageLookupByLibrary.simpleMessage("10 minutes"), diff --git a/lib/generated/intl/messages_zh_CN.dart b/lib/generated/intl/messages_zh_CN.dart index a9598fac3..1ce397a54 100644 --- a/lib/generated/intl/messages_zh_CN.dart +++ b/lib/generated/intl/messages_zh_CN.dart @@ -70,6 +70,8 @@ class MessageLookup extends MessageLookupByLibrary { "back": MessageLookupByLibrary.simpleMessage("后退"), "balance_not_enough": MessageLookupByLibrary.simpleMessage("余额不足"), "biometrics": MessageLookupByLibrary.simpleMessage("生物识别"), + "block": MessageLookupByLibrary.simpleMessage("屏蔽"), + "block_tips": MessageLookupByLibrary.simpleMessage("启用后,将屏蔽对方的消息。"), "blocked_user_disallow_invite": MessageLookupByLibrary.simpleMessage("该用户已经被拉黑,不允许普通成员邀请"), "burn_10_minutes": MessageLookupByLibrary.simpleMessage("10 分钟"), diff --git a/lib/generated/intl/messages_zh_TW.dart b/lib/generated/intl/messages_zh_TW.dart index b7b88a93a..3f92b3e20 100644 --- a/lib/generated/intl/messages_zh_TW.dart +++ b/lib/generated/intl/messages_zh_TW.dart @@ -70,6 +70,8 @@ class MessageLookup extends MessageLookupByLibrary { "back": MessageLookupByLibrary.simpleMessage("後退"), "balance_not_enough": MessageLookupByLibrary.simpleMessage("餘額不足"), "biometrics": MessageLookupByLibrary.simpleMessage("生物識別"), + "block": MessageLookupByLibrary.simpleMessage("屏蔽"), + "block_tips": MessageLookupByLibrary.simpleMessage("啟用後,將封鎖對方的訊息。"), "blocked_user_disallow_invite": MessageLookupByLibrary.simpleMessage("該用戶已經被拉黑,不允許普通成員邀請"), "burn_10_minutes": MessageLookupByLibrary.simpleMessage("10 分鐘"), diff --git a/lib/generated/l10n.dart b/lib/generated/l10n.dart index 43f5468ad..5edc102e7 100644 --- a/lib/generated/l10n.dart +++ b/lib/generated/l10n.dart @@ -4079,6 +4079,26 @@ class S { args: [], ); } + + /// `Block` + String get block { + return Intl.message( + 'Block', + name: 'block', + desc: '', + args: [], + ); + } + + /// `After enabling, messages from this user will be blocked.` + String get block_tips { + return Intl.message( + 'After enabling, messages from this user will be blocked.', + name: 'block_tips', + desc: '', + args: [], + ); + } } class AppLocalizationDelegate extends LocalizationsDelegate { diff --git a/lib/l10n/intl_en.arb b/lib/l10n/intl_en.arb index eaefffbc4..a71d5ec5d 100644 --- a/lib/l10n/intl_en.arb +++ b/lib/l10n/intl_en.arb @@ -420,5 +420,7 @@ "allow_uploading_application_exception_logs": "Allow uploading application exception logs", "do_not_allow_uploading_application_exception_logs": "Do not allow uploading application exception logs", "developer_options": "developer options", - "message_debug_info": "message debug information" + "message_debug_info": "message debug information", + "block": "Block", + "block_tips": "After enabling, messages from this user will be blocked." } \ No newline at end of file diff --git a/lib/l10n/intl_zh_CN.arb b/lib/l10n/intl_zh_CN.arb index dde884fc2..0429b71cd 100644 --- a/lib/l10n/intl_zh_CN.arb +++ b/lib/l10n/intl_zh_CN.arb @@ -420,5 +420,7 @@ "allow_uploading_application_exception_logs": "允许上传应用异常日志", "do_not_allow_uploading_application_exception_logs": "不允许上传应用异常日志", "developer_options": "开发人员选项", - "message_debug_info": "消息调试信息" + "message_debug_info": "消息调试信息", + "block": "屏蔽", + "block_tips": "启用后,将屏蔽对方的消息。" } \ No newline at end of file diff --git a/lib/l10n/intl_zh_TW.arb b/lib/l10n/intl_zh_TW.arb index e98e39378..491adfe7d 100644 --- a/lib/l10n/intl_zh_TW.arb +++ b/lib/l10n/intl_zh_TW.arb @@ -420,5 +420,7 @@ "allow_uploading_application_exception_logs": "允許上傳應用異常日誌", "do_not_allow_uploading_application_exception_logs": "不允許上傳應用異常日誌", "developer_options": "开发人员选项", - "message_debug_info": "消息调试信息" + "message_debug_info": "消息调试信息", + "block": "屏蔽", + "block_tips": "啟用後,將封鎖對方的訊息。" } \ No newline at end of file diff --git a/lib/screens/contact/profile.dart b/lib/screens/contact/profile.dart index 033457b53..3abfbbaf0 100644 --- a/lib/screens/contact/profile.dart +++ b/lib/screens/contact/profile.dart @@ -4,6 +4,7 @@ import 'dart:io'; import 'package:flutter/cupertino.dart'; import 'package:flutter/material.dart'; import 'package:flutter_slidable/flutter_slidable.dart'; +import 'package:font_awesome_flutter/font_awesome_flutter.dart'; import 'package:image_cropper/image_cropper.dart'; import 'package:nmobile/app.dart'; import 'package:nmobile/common/locator.dart'; @@ -114,6 +115,7 @@ class _ContactProfileScreenState extends BaseStateFulWidgetState event.address == _contact?.address).listen((ContactSchema event) { _initBurning(event); _initNotification(event); + _initBlocked(event); setState(() { _contact = event; }); @@ -170,6 +173,7 @@ class _ContactProfileScreenState extends BaseStateFulWidgetState _refreshDefaultWallet({WalletSchema? wallet}) async { wallet = wallet ?? await walletCommon.getDefault(); if (wallet == null) { @@ -864,7 +875,7 @@ class _ContactProfileScreenState extends BaseStateFulWidgetState[ + Icon(FontAwesomeIcons.userSlash, size: 24, color: application.theme.fallColor), + SizedBox(width: 10), + Label( + Settings.locale((s) => s.block, ctx: context), + type: LabelType.bodyRegular, + color: application.theme.fallColor, + ), + Spacer(), + CupertinoSwitch( + value: _blocked, + activeTrackColor: application.theme.fallColor, + onChanged: (value) async { + setState(() { + _blocked = value; + }); + await contactCommon.setBlocked(_contact?.address, value, notify: true); + }, + ), + ], + ), + ), + Padding( + padding: const EdgeInsets.only(top: 6, left: 20, right: 20), + child: Label( + Settings.locale((s) => s.block_tips, ctx: context), + type: LabelType.bodySmall, + color: application.theme.fallColor, + fontWeight: FontWeight.w600, + softWrap: true, + ), + ), + SizedBox(height: 28), + /// sendMsg TextButton( style: _buttonStyle(topRadius: true, botRadius: true, topPad: 12, botPad: 12), @@ -1011,7 +1061,7 @@ class _ContactProfileScreenState extends BaseStateFulWidgetState[ CupertinoSwitch( value: _bubbleEnable, - activeColor: application.theme.primaryColor, + activeTrackColor: application.theme.primaryColor, onChanged: (bool value) async { Settings.messageDebugInfo = value; SettingsStorage.setSettings('${SettingsStorage.OPEN_DEVELOP_OPTIONS_MESSAGE_DEBUG}', value); diff --git a/lib/screens/settings/home.dart b/lib/screens/settings/home.dart index aefe0505f..c7910c4d4 100644 --- a/lib/screens/settings/home.dart +++ b/lib/screens/settings/home.dart @@ -317,7 +317,7 @@ class _SettingsHomeScreenState extends BaseStateFulWidgetState[ CupertinoSwitch( value: _biometricsSelected, - activeColor: application.theme.primaryColor, + activeTrackColor: application.theme.primaryColor, onChanged: (bool value) async { WalletSchema? _wallet = await walletCommon.getDefault(); if (_wallet == null || _wallet.address.isEmpty) { diff --git a/lib/screens/settings/subscribe.dart b/lib/screens/settings/subscribe.dart index 3fb190e83..d602f3e5b 100644 --- a/lib/screens/settings/subscribe.dart +++ b/lib/screens/settings/subscribe.dart @@ -183,7 +183,7 @@ class _SettingsAccelerateScreenState extends BaseStateFulWidgetState[ CupertinoSwitch( value: _subscribeSpeedEnable, - activeColor: application.theme.primaryColor, + activeTrackColor: application.theme.primaryColor, onChanged: (bool value) async { SettingsStorage.setSettings('${SettingsStorage.DEFAULT_TOPIC_SUBSCRIBE_SPEED_ENABLE}', value); setState(() { @@ -236,7 +236,7 @@ class _SettingsAccelerateScreenState extends BaseStateFulWidgetState[ CupertinoSwitch( value: _resubscribeSpeedEnable, - activeColor: application.theme.primaryColor, + activeTrackColor: application.theme.primaryColor, onChanged: (bool value) async { SettingsStorage.setSettings('${SettingsStorage.DEFAULT_TOPIC_RESUBSCRIBE_SPEED_ENABLE}', value); setState(() { diff --git a/lib/screens/settings/tracker.dart b/lib/screens/settings/tracker.dart index c0978c23e..0ecf50beb 100644 --- a/lib/screens/settings/tracker.dart +++ b/lib/screens/settings/tracker.dart @@ -76,7 +76,7 @@ class _SettingsTrackerScreenState extends BaseStateFulWidgetState[ CupertinoSwitch( value: _pushEnable, - activeColor: application.theme.primaryColor, + activeTrackColor: application.theme.primaryColor, onChanged: (bool value) async { SettingsStorage.setSettings('${SettingsStorage.CLOSE_NOTIFICATION_PUSH_API}', !value); Settings.notificationPushEnable = value; @@ -130,7 +130,7 @@ class _SettingsTrackerScreenState extends BaseStateFulWidgetState[ CupertinoSwitch( value: _bugEnable, - activeColor: application.theme.primaryColor, + activeTrackColor: application.theme.primaryColor, onChanged: (bool value) async { SettingsStorage.setSettings('${SettingsStorage.CLOSE_BUG_UPLOAD_API}', !value); Settings.sentryEnable = value; From 6cb18e8a60b78f2a5f53b555cdb306b74365b6b5 Mon Sep 17 00:00:00 2001 From: Heron Date: Fri, 29 Aug 2025 18:09:29 +0800 Subject: [PATCH 07/47] feat(contacts): export/import contacts --- devtools_options.yaml | 3 + lib/generated/intl/messages_en.dart | 4 + lib/generated/intl/messages_zh_CN.dart | 2 + lib/generated/intl/messages_zh_TW.dart | 2 + lib/generated/l10n.dart | 20 +++ lib/l10n/intl_en.arb | 3 + lib/l10n/intl_zh_CN.arb | 3 + lib/l10n/intl_zh_TW.arb | 3 + lib/screens/contact/home.dart | 81 ++++++++++- lib/screens/contact/home_empty.dart | 56 ++++++++ lib/utils/contact_io.dart | 180 +++++++++++++++++++++++++ 11 files changed, 356 insertions(+), 1 deletion(-) create mode 100644 devtools_options.yaml create mode 100644 lib/utils/contact_io.dart diff --git a/devtools_options.yaml b/devtools_options.yaml new file mode 100644 index 000000000..fa0b357c4 --- /dev/null +++ b/devtools_options.yaml @@ -0,0 +1,3 @@ +description: This file stores settings for Dart & Flutter DevTools. +documentation: https://docs.flutter.dev/tools/devtools/extensions#configure-extension-enablement-states +extensions: diff --git a/lib/generated/intl/messages_en.dart b/lib/generated/intl/messages_en.dart index 30b8b1895..eb5e8f7fa 100644 --- a/lib/generated/intl/messages_en.dart +++ b/lib/generated/intl/messages_en.dart @@ -271,6 +271,8 @@ class MessageLookup extends MessageLookupByLibrary { "expiration": MessageLookupByLibrary.simpleMessage("Expiration"), "expired": MessageLookupByLibrary.simpleMessage("Expired"), "export": MessageLookupByLibrary.simpleMessage("Export"), + "export_contacts": + MessageLookupByLibrary.simpleMessage("Export Contacts"), "export_wallet": MessageLookupByLibrary.simpleMessage("Export Account"), "face_id": MessageLookupByLibrary.simpleMessage("Face ID"), "failure": MessageLookupByLibrary.simpleMessage("Failure"), @@ -303,6 +305,8 @@ class MessageLookup extends MessageLookupByLibrary { MessageLookupByLibrary.simpleMessage("Enter wallet name"), "hours": MessageLookupByLibrary.simpleMessage("hours"), "image": MessageLookupByLibrary.simpleMessage("Image"), + "import_contacts": + MessageLookupByLibrary.simpleMessage("Import Contacts"), "import_ethereum_wallet": MessageLookupByLibrary.simpleMessage("Import Ethereum Account"), "import_nkn_wallet": diff --git a/lib/generated/intl/messages_zh_CN.dart b/lib/generated/intl/messages_zh_CN.dart index 1ce397a54..46f2775d3 100644 --- a/lib/generated/intl/messages_zh_CN.dart +++ b/lib/generated/intl/messages_zh_CN.dart @@ -228,6 +228,7 @@ class MessageLookup extends MessageLookupByLibrary { "expiration": MessageLookupByLibrary.simpleMessage("过期时间"), "expired": MessageLookupByLibrary.simpleMessage("已过期"), "export": MessageLookupByLibrary.simpleMessage("导出"), + "export_contacts": MessageLookupByLibrary.simpleMessage("导出联系人"), "export_wallet": MessageLookupByLibrary.simpleMessage("导出账户"), "face_id": MessageLookupByLibrary.simpleMessage("Face ID"), "failure": MessageLookupByLibrary.simpleMessage("失败"), @@ -255,6 +256,7 @@ class MessageLookup extends MessageLookupByLibrary { "hint_enter_wallet_name": MessageLookupByLibrary.simpleMessage("账户名称"), "hours": MessageLookupByLibrary.simpleMessage("小时"), "image": MessageLookupByLibrary.simpleMessage("图片"), + "import_contacts": MessageLookupByLibrary.simpleMessage("导入联系人"), "import_ethereum_wallet": MessageLookupByLibrary.simpleMessage("导入以太坊账户"), "import_nkn_wallet": MessageLookupByLibrary.simpleMessage("导入主网账户"), diff --git a/lib/generated/intl/messages_zh_TW.dart b/lib/generated/intl/messages_zh_TW.dart index 3f92b3e20..ee433ea72 100644 --- a/lib/generated/intl/messages_zh_TW.dart +++ b/lib/generated/intl/messages_zh_TW.dart @@ -228,6 +228,7 @@ class MessageLookup extends MessageLookupByLibrary { "expiration": MessageLookupByLibrary.simpleMessage("過期時間"), "expired": MessageLookupByLibrary.simpleMessage("已過期"), "export": MessageLookupByLibrary.simpleMessage("導出"), + "export_contacts": MessageLookupByLibrary.simpleMessage("導出聯繫人"), "export_wallet": MessageLookupByLibrary.simpleMessage("導出賬戶"), "face_id": MessageLookupByLibrary.simpleMessage("Face ID"), "failure": MessageLookupByLibrary.simpleMessage("失敗"), @@ -255,6 +256,7 @@ class MessageLookup extends MessageLookupByLibrary { "hint_enter_wallet_name": MessageLookupByLibrary.simpleMessage("賬戶名稱"), "hours": MessageLookupByLibrary.simpleMessage("小時"), "image": MessageLookupByLibrary.simpleMessage("圖片"), + "import_contacts": MessageLookupByLibrary.simpleMessage("導入聯繫人"), "import_ethereum_wallet": MessageLookupByLibrary.simpleMessage("導入以太坊賬戶"), "import_nkn_wallet": MessageLookupByLibrary.simpleMessage("導入主網賬戶"), diff --git a/lib/generated/l10n.dart b/lib/generated/l10n.dart index 5edc102e7..62bcc7331 100644 --- a/lib/generated/l10n.dart +++ b/lib/generated/l10n.dart @@ -2090,6 +2090,26 @@ class S { ); } + /// `Export Contacts` + String get export_contacts { + return Intl.message( + 'Export Contacts', + name: 'export_contacts', + desc: '', + args: [], + ); + } + + /// `Import Contacts` + String get import_contacts { + return Intl.message( + 'Import Contacts', + name: 'import_contacts', + desc: '', + args: [], + ); + } + /// `Type a message` String get type_a_message { return Intl.message( diff --git a/lib/l10n/intl_en.arb b/lib/l10n/intl_en.arb index a71d5ec5d..85373cb46 100644 --- a/lib/l10n/intl_en.arb +++ b/lib/l10n/intl_en.arb @@ -221,6 +221,9 @@ "contact_no_contact_title": "You haven’t got any\n contacts yet", "contact_no_contact_desc": "Use your contact list to quickly message and\n send funds to your friends.", + "export_contacts": "Export Contacts", + "import_contacts": "Import Contacts", + "type_a_message": "Type a message", "pictures": "Pictures", "album": "Album", diff --git a/lib/l10n/intl_zh_CN.arb b/lib/l10n/intl_zh_CN.arb index 0429b71cd..21e110899 100644 --- a/lib/l10n/intl_zh_CN.arb +++ b/lib/l10n/intl_zh_CN.arb @@ -221,6 +221,9 @@ "contact_no_contact_title": "您还没有联系人", "contact_no_contact_desc": "使用您的联系人列表快速发送消息和资金。", + "export_contacts": "导出联系人", + "import_contacts": "导入联系人", + "type_a_message": "请输入", "pictures": "图片", "album": "相册", diff --git a/lib/l10n/intl_zh_TW.arb b/lib/l10n/intl_zh_TW.arb index 491adfe7d..c500c5263 100644 --- a/lib/l10n/intl_zh_TW.arb +++ b/lib/l10n/intl_zh_TW.arb @@ -221,6 +221,9 @@ "contact_no_contact_title": "您還沒有聯繫人", "contact_no_contact_desc": "使用您的聯繫人列表快速發送消息和資金。", + "export_contacts": "導出聯繫人", + "import_contacts": "導入聯繫人", + "type_a_message": "請輸入", "pictures": "圖片", "album": "相冊", diff --git a/lib/screens/contact/home.dart b/lib/screens/contact/home.dart index 3dafbfa02..7da0a4ac7 100644 --- a/lib/screens/contact/home.dart +++ b/lib/screens/contact/home.dart @@ -1,7 +1,9 @@ import 'dart:async'; +import 'dart:io'; import 'package:flutter/material.dart'; import 'package:flutter_slidable/flutter_slidable.dart'; +import 'package:file_picker/file_picker.dart'; import 'package:nmobile/common/locator.dart'; import 'package:nmobile/common/settings.dart'; import 'package:nmobile/components/base/stateful.dart'; @@ -24,8 +26,12 @@ import 'package:nmobile/screens/contact/add.dart'; import 'package:nmobile/screens/contact/home_empty.dart'; import 'package:nmobile/screens/contact/profile.dart'; import 'package:nmobile/utils/asset.dart'; +import 'package:nmobile/utils/util.dart'; import 'package:nmobile/utils/time.dart'; +import '../../helpers/error.dart'; +import 'package:nmobile/utils/contact_io.dart'; + class ContactHomeScreen extends BaseStateFulWidget { static const String routeName = '/contact/home'; static final String argNavTitle = "nav_title"; @@ -81,6 +87,58 @@ class _ContactHomeScreenState extends BaseStateFulWidgetState List _searchTopics = []; List _searchGroups = []; + Future _exportContacts() async { + try { + Loading.show(); + String? path = await ContactIO.exportFriendsAsJson(); + Loading.dismiss(); + if (path == null) { + Toast.show(Settings.locale((s) => s.something_went_wrong, ctx: context)); + return; + } + Toast.show(Settings.locale((s) => s.success, ctx: context)); + Util.launchFile(path); + } catch (e, st) { + Loading.dismiss(); + handleError(e, st); + Toast.show(Settings.locale((s) => s.something_went_wrong, ctx: context)); + } + } + + Future _importContacts() async { + try { + FilePickerResult? result = await FilePicker.platform.pickFiles( + allowMultiple: false, + type: Platform.isAndroid ? FileType.any : FileType.custom, + allowedExtensions: Platform.isAndroid ? null : ["json"], + ); + if (result == null || result.files.isEmpty) return; + String? path = result.files.first.path; + if (path == null) return; + File picked = File(path); + if (!path.toLowerCase().endsWith('.json')) { + if (!mounted) return; + Toast.show(Settings.locale((s) => s.something_went_wrong, ctx: Settings.appContext)); + return; + } + if (!await picked.exists()) { + if (!mounted) return; + Toast.show(Settings.locale((s) => s.file_not_exist, ctx: Settings.appContext)); + return; + } + Loading.show(); + int imported = await ContactIO.importContactsFromJsonFile(picked); + Loading.dismiss(); + if (!mounted) return; + Toast.show('${Settings.locale((s) => s.success, ctx: Settings.appContext)} ($imported)'); + } catch (e, st) { + Loading.dismiss(); + handleError(e, st); + if (!mounted) return; + Toast.show(Settings.locale((s) => s.something_went_wrong, ctx: Settings.appContext)); + } + } + @override void onRefreshArguments() { this._navTitle = widget.arguments?[ContactHomeScreen.argNavTitle] ?? ""; @@ -337,7 +395,28 @@ class _ContactHomeScreenState extends BaseStateFulWidgetState onPressed: () { ContactAddScreen.go(context); }, - ) + ), + PopupMenuButton( + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)), + icon: Asset.iconSvg('more', color: application.theme.backgroundLightColor, width: 24), + onSelected: (int result) { + if (result == 0) { + _exportContacts(); + } else if (result == 1) { + _importContacts(); + } + }, + itemBuilder: (BuildContext context) => >[ + PopupMenuItem( + value: 0, + child: Label(Settings.locale((s) => s.export_contacts, ctx: context), type: LabelType.display), + ), + PopupMenuItem( + value: 1, + child: Label(Settings.locale((s) => s.import_contacts, ctx: context), type: LabelType.display), + ), + ], + ), ], ), body: GestureDetector( diff --git a/lib/screens/contact/home_empty.dart b/lib/screens/contact/home_empty.dart index a2b67ee97..b9f12de95 100644 --- a/lib/screens/contact/home_empty.dart +++ b/lib/screens/contact/home_empty.dart @@ -1,13 +1,20 @@ +import 'dart:io'; + +import 'package:file_picker/file_picker.dart'; import 'package:flutter/material.dart'; import 'package:nmobile/common/locator.dart'; import 'package:nmobile/common/settings.dart'; import 'package:nmobile/components/base/stateful.dart'; import 'package:nmobile/components/button/button.dart'; +import 'package:nmobile/components/dialog/loading.dart'; import 'package:nmobile/components/layout/header.dart'; import 'package:nmobile/components/layout/layout.dart'; import 'package:nmobile/components/text/label.dart'; +import 'package:nmobile/components/tip/toast.dart'; import 'package:nmobile/screens/contact/add.dart'; import 'package:nmobile/utils/asset.dart'; +import 'package:nmobile/utils/contact_io.dart'; +import 'package:nmobile/helpers/error.dart'; class ContactHomeEmptyLayout extends BaseStateFulWidget { @override @@ -18,6 +25,40 @@ class _ContactHomeEmptyLayoutState extends BaseStateFulWidgetState _importContacts() async { + try { + FilePickerResult? result = await FilePicker.platform.pickFiles( + allowMultiple: false, + type: Platform.isAndroid ? FileType.any : FileType.custom, + allowedExtensions: Platform.isAndroid ? null : ["json"], + ); + if (result == null || result.files.isEmpty) return; + String? path = result.files.first.path; + if (path == null) return; + File picked = File(path); + if (!path.toLowerCase().endsWith('.json')) { + if (!mounted) return; + Toast.show(Settings.locale((s) => s.something_went_wrong, ctx: Settings.appContext)); + return; + } + if (!await picked.exists()) { + if (!mounted) return; + Toast.show(Settings.locale((s) => s.file_not_exist, ctx: Settings.appContext)); + return; + } + Loading.show(); + int imported = await ContactIO.importContactsFromJsonFile(picked); + Loading.dismiss(); + if (!mounted) return; + Toast.show('${Settings.locale((s) => s.success, ctx: Settings.appContext)} ($imported)'); + } catch (e, st) { + Loading.dismiss(); + handleError(e, st); + if (!mounted) return; + Toast.show(Settings.locale((s) => s.something_went_wrong, ctx: Settings.appContext)); + } + } + @override Widget build(BuildContext context) { double imgSize = Settings.screenWidth() / 2; @@ -37,6 +78,21 @@ class _ContactHomeEmptyLayoutState extends BaseStateFulWidgetState( + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)), + icon: Asset.iconSvg('more', width: 24), + onSelected: (int result) { + if (result == 1) { + _importContacts(); + } + }, + itemBuilder: (BuildContext context) => >[ + PopupMenuItem( + value: 1, + child: Label(Settings.locale((s) => s.import_contacts, ctx: context), type: LabelType.display), + ), + ], + ), ], ), body: Center( diff --git a/lib/utils/contact_io.dart b/lib/utils/contact_io.dart new file mode 100644 index 000000000..0e219ce43 --- /dev/null +++ b/lib/utils/contact_io.dart @@ -0,0 +1,180 @@ +import 'dart:convert'; +import 'dart:io'; +import 'dart:typed_data'; + +import 'package:nmobile/common/locator.dart'; +import 'package:nmobile/schema/contact.dart'; +import 'package:nmobile/utils/path.dart'; +import 'package:nmobile/helpers/error.dart'; +import 'package:file_picker/file_picker.dart'; +import 'package:path/path.dart' as p; +import 'package:path_provider/path_provider.dart'; + +import '../storages/contact.dart'; + +class ContactIO { + /// Export all friend contacts to a JSON file. + /// Priority: show save dialog -> Downloads (if available) -> app private dir. + /// Returns the absolute file path if successful; otherwise null. + static Future exportFriendsAsJson() async { + try { + final String fileName = 'contacts_${DateTime.now().millisecondsSinceEpoch}.json'; + // 1) Build JSON content + final String jsonText = await _buildFriendsJson(); + + // 2) Try save dialog (user picks path) + try { + final Uint8List bytes = Uint8List.fromList(utf8.encode(jsonText)); + String? pickedPath = await FilePicker.platform.saveFile( + dialogTitle: 'Save contacts', + fileName: fileName, + type: FileType.custom, + allowedExtensions: ['json'], + bytes: bytes, + ); + if (pickedPath != null && pickedPath.isNotEmpty) { + // On Android/iOS, file has been saved by the picker using provided bytes. + // Some platforms still return the picked path; return it for consistency. + return pickedPath; + } + } catch (e, st) { + // ignore and fallback + handleError(e, st); + } + + // 3) Try system Downloads (Desktop/macOS/Windows; may be null on mobile) + try { + final dir = await getDownloadsDirectory(); + if (dir != null) { + final savePath = p.join(dir.path, fileName); + final out = File(savePath); + if (!await out.exists()) { + await out.create(recursive: true); + } + await out.writeAsString(jsonText, flush: true); + return out.path; + } + } catch (e, st) { + // ignore and fallback + handleError(e, st); + } + + // 4) Fallback to app private dir + String filePath = await Path.createFile( + clientCommon.getPublicKey(), + DirType.download, + fileName.replaceAll('.json', ''), + fileExt: 'json', + ); + File file = File(filePath); + await file.writeAsString(jsonText, flush: true); + return file.path; + } catch (e, st) { + handleError(e, st); + return null; + } + } + + static Future _buildFriendsJson() async { + const int pageSize = 200; + List friends = []; + for (int offset = 0; true; offset += pageSize) { + List result = await contactCommon.queryList(type: ContactType.friend, offset: offset, limit: pageSize); + friends.addAll(result); + if (result.length < pageSize) break; + } + List> items = friends.map((e) => e.toMap()).toList(); + Map exportPayload = { + "version": 1, + "type": "contacts", + "count": items.length, + "items": items, + }; + return const JsonEncoder.withIndent(' ').convert(exportPayload); + } + + /// Import contacts from a JSON file previously exported by [exportFriendsAsJson]. + /// Merge strategy: + /// - If address not exists: add as friend + /// - If exists: update fields (type->friend, names, remark, wallet, top, options, data merge) + /// Returns the number of contacts added or changed. + static Future importContactsFromJsonFile(File? file) async { + if (file == null || !await file.exists()) return 0; + int importedCount = 0; + try { + String content = await file.readAsString(); + Map data = jsonDecode(content) as Map; + List items = (data["items"] is List) ? (data["items"] as List) : []; + for (var i = 0; i < items.length; i++) { + var e = items[i]; + if (e is! Map) continue; + try { + ContactSchema schema = ContactSchema.fromMap(e as Map); + if (schema.address.isEmpty) continue; + if (schema.type == ContactType.me) continue; // skip self data + schema.type = ContactType.friend; + ContactSchema? exist = await contactCommon.query(schema.address, fetchWalletAddress: false); + if (exist == null) { + await contactCommon.add(schema, fetchWalletAddress: true, notify: true); + importedCount++; + continue; + } + bool changed = false; + if (exist.type == ContactType.me) { + // never modify self-contact + continue; + } + if (exist.type != ContactType.friend) { + await contactCommon.setType(schema.address, ContactType.friend, notify: true); + changed = true; + } + // full name + String newFirst = (schema.firstName.isNotEmpty) ? schema.firstName : exist.firstName; + String newLast = (schema.lastName.isNotEmpty) ? schema.lastName : exist.lastName; + if (newFirst != exist.firstName || newLast != exist.lastName) { + await ContactStorage.instance.setFullName(schema.address, newFirst, newLast); + changed = true; + } + // remark + if (schema.remarkName.isNotEmpty && schema.remarkName != exist.remarkName) { + await contactCommon.setOtherRemarkName(schema.address, schema.remarkName, notify: true); + changed = true; + } + // wallet address + if (schema.walletAddress.isNotEmpty && schema.walletAddress != exist.walletAddress) { + await contactCommon.setWalletAddress(schema.address, schema.walletAddress, notify: true); + changed = true; + } + // top + if (schema.isTop != exist.isTop) { + await contactCommon.setTop(schema.address, schema.isTop, notify: true); + changed = true; + } + // options + if (schema.options.notificationOpen != exist.options.notificationOpen) { + await contactCommon.setNotificationOpen(schema.address, schema.options.notificationOpen, notify: true); + changed = true; + } + if (schema.options.deleteAfterSeconds != exist.options.deleteAfterSeconds || + schema.options.updateBurnAfterAt != exist.options.updateBurnAfterAt) { + await contactCommon.setOptionsBurn(schema.address, schema.options.deleteAfterSeconds, schema.options.updateBurnAfterAt, notify: true); + changed = true; + } + // merge custom data (shallow add/overwrite) + if (schema.data.isNotEmpty) { + await ContactStorage.instance.setData(schema.address, schema.data); + changed = true; + } + if (changed) importedCount++; + } catch (e) { + // ignore invalid + } + } + } catch (e, st) { + handleError(e, st); + } + return importedCount; + } +} + + From 6770856e1a8b4fcfc3f027006c3ae92f082324fd Mon Sep 17 00:00:00 2001 From: Heron Date: Sat, 30 Aug 2025 15:06:12 +0800 Subject: [PATCH 08/47] feat(chat): message revoke protocol --- android/app/build.gradle.kts | 2 +- ios/Runner.xcodeproj/project.pbxproj | 6 +- lib/common/chat/chat_in.dart | 24 ++++++ lib/common/chat/chat_out.dart | 35 ++++++++ lib/components/chat/bubble.dart | 110 ++++++++++++++++++++----- lib/generated/intl/messages_en.dart | 3 + lib/generated/intl/messages_zh_CN.dart | 2 + lib/generated/intl/messages_zh_TW.dart | 2 + lib/generated/l10n.dart | 20 +++++ lib/l10n/intl_en.arb | 4 +- lib/l10n/intl_zh_CN.arb | 4 +- lib/l10n/intl_zh_TW.arb | 4 +- lib/schema/message.dart | 20 +++++ pubspec.yaml | 2 +- 14 files changed, 209 insertions(+), 29 deletions(-) diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts index 998fc5639..26dcd03ee 100644 --- a/android/app/build.gradle.kts +++ b/android/app/build.gradle.kts @@ -27,7 +27,7 @@ android { // You can update the following values to match your application needs. // For more information, see: https://flutter.dev/to/review-gradle-config. minSdk = 24 - targetSdk = flutter.targetSdkVersion + targetSdk = 36 versionCode = flutter.versionCode versionName = flutter.versionName } diff --git a/ios/Runner.xcodeproj/project.pbxproj b/ios/Runner.xcodeproj/project.pbxproj index 70ea2ee11..37a0ca386 100644 --- a/ios/Runner.xcodeproj/project.pbxproj +++ b/ios/Runner.xcodeproj/project.pbxproj @@ -829,7 +829,7 @@ CODE_SIGN_ENTITLEMENTS = Runner/Runner.entitlements; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 331; + CURRENT_PROJECT_VERSION = 332; DEVELOPMENT_TEAM = 67P82ZQDAS; ENABLE_BITCODE = NO; INFOPLIST_FILE = Runner/Info.plist; @@ -966,7 +966,7 @@ CODE_SIGN_ENTITLEMENTS = Runner/Runner.entitlements; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 331; + CURRENT_PROJECT_VERSION = 332; DEVELOPMENT_TEAM = 67P82ZQDAS; ENABLE_BITCODE = NO; INFOPLIST_FILE = Runner/Info.plist; @@ -995,7 +995,7 @@ CODE_SIGN_ENTITLEMENTS = Runner/Runner.entitlements; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 331; + CURRENT_PROJECT_VERSION = 332; DEVELOPMENT_TEAM = 67P82ZQDAS; ENABLE_BITCODE = NO; INFOPLIST_FILE = Runner/Info.plist; diff --git a/lib/common/chat/chat_in.dart b/lib/common/chat/chat_in.dart index a79089559..a7c604dd6 100644 --- a/lib/common/chat/chat_in.dart +++ b/lib/common/chat/chat_in.dart @@ -18,6 +18,8 @@ import 'package:nmobile/utils/logger.dart'; import 'package:nmobile/utils/parallel_queue.dart'; import 'package:nmobile/utils/path.dart'; +import '../../storages/message.dart'; + class ChatInCommon with Tag { ChatInCommon(); @@ -235,6 +237,9 @@ class ChatInCommon with Tag { case MessageContentType.topicKickOut: await _receiveTopicKickOut(received); break; + case MessageContentType.revoke: + await _receiveRevoke(received); + break; case MessageContentType.privateGroupInvitation: insertOk = await _receivePrivateGroupInvitation(received); break; @@ -948,6 +953,25 @@ class ChatInCommon with Tag { await privateGroupCommon.updatePrivateGroupMembers(received.sender, groupId, version, members); } + Future _receiveRevoke(MessageSchema received) async { + // content is target msgId + String? targetMsgId = received.content?.toString(); + if (targetMsgId == null || targetMsgId.isEmpty) return false; + MessageSchema? target = await messageCommon.query(targetMsgId); + if (target == null) return false; + // Only sender can revoke + String sender = received.sender; + bool isSameSender = target.isOutbound + ? (clientCommon.address == sender) + : (target.sender == sender); + if (!isSameSender) return false; + // Soft delete first + await MessageStorage.instance.updateIsDelete(target.msgId, true); + // Optional: deep delete pieces/content + await messageCommon.messageDelete(target, notify: true); + return true; + } + Future _deletePieces(String msgId) async { final limit = 20; List pieces = []; diff --git a/lib/common/chat/chat_out.dart b/lib/common/chat/chat_out.dart index 63ac72041..cb6bcc475 100644 --- a/lib/common/chat/chat_out.dart +++ b/lib/common/chat/chat_out.dart @@ -865,6 +865,41 @@ class ChatOutCommon with Tag { return await _sendVisible(message, maxHoldingSeconds: maxHoldingSeconds); } + Future sendRevoke(String msgId) async { + if (msgId.isEmpty) return false; + // Only allow sender to revoke + MessageSchema? origin = await messageCommon.query(msgId); + if (origin == null || !origin.isOutbound) return false; + + String targetId = origin.targetId; + int targetType = origin.targetType; + // Build revoke message schema per target type + MessageSchema revoke = MessageSchema.fromSend( + targetId, + targetType, + MessageContentType.revoke, + msgId, + ); + // no DB persistence for revoke command + revoke.data = MessageData.getRevoke(msgId); + + // Send according to targetType + Uint8List? pid; + if (targetType == SessionType.TOPIC) { + TopicSchema? topic = TopicSchema.create(targetId, type: SessionType.TOPIC); + pid = await _sendWithTopic(topic, revoke, notification: false); + } else if (targetType == SessionType.PRIVATE_GROUP) { + PrivateGroupSchema? group = PrivateGroupSchema.create(targetId, targetId, type: SessionType.PRIVATE_GROUP); + pid = await _sendWithPrivateGroup(group, revoke, notification: false); + } else { + ContactSchema? contact = await chatCommon.contactHandle(revoke); + pid = await _sendWithContact(contact, revoke, notification: false); + } + bool ok = pid?.isNotEmpty == true; + logger.i("$TAG - sendRevoke - type:$targetType - dest:$targetId - msgId:$msgId - ok:$ok"); + return ok; + } + Future saveIpfs(dynamic target, Map data) async { // content String contentPath = data["path"]?.toString() ?? ""; diff --git a/lib/components/chat/bubble.dart b/lib/components/chat/bubble.dart index 221f3df59..0e3345748 100644 --- a/lib/components/chat/bubble.dart +++ b/lib/components/chat/bubble.dart @@ -297,6 +297,9 @@ class _ChatBubbleState extends BaseStateFulWidgetState with Tag { } var onTap = _onTapBubble(contentType); + var onLongPress = () { + _showPopupMenu(includeCopy: contentType == MessageContentType.text || contentType == MessageContentType.textExtension); + }; List childs = [SizedBox.shrink()]; switch (contentType) { @@ -325,6 +328,7 @@ class _ChatBubbleState extends BaseStateFulWidgetState with Tag { return GestureDetector( key: _contentKey, onTap: onTap, + onLongPress: onLongPress, child: Container( constraints: BoxConstraints(maxWidth: maxWidth), padding: EdgeInsets.only(left: 10, right: 10, top: 10, bottom: 5), @@ -345,27 +349,7 @@ class _ChatBubbleState extends BaseStateFulWidgetState with Tag { switch (contentType) { case MessageContentType.text: case MessageContentType.textExtension: - onTap = () { - PopMenu.PopupMenu popupMenu = PopMenu.PopupMenu( - context: context, - items: [ - PopMenu.MenuItem( - userInfo: 0, - title: Settings.locale((s) => s.copy, ctx: context), - textStyle: TextStyle(color: application.theme.fontLightColor, fontSize: 12), - ), - ], - onClickMenu: (PopMenu.MenuItemProvider item) { - var index = (item as PopMenu.MenuItem).userInfo; - switch (index) { - case 0: - Util.copyText(_message.content?.toString() ?? ""); - break; - } - }, - ); - popupMenu.show(widgetKey: _contentKey); - }; + onTap = () => _showPopupMenu(includeCopy: true); break; case MessageContentType.image: // image + ipfs_image @@ -446,6 +430,90 @@ class _ChatBubbleState extends BaseStateFulWidgetState with Tag { return onTap; } + void _showPopupMenu({bool includeCopy = false}) { + List items = []; + if (includeCopy) { + items.add(PopMenu.MenuItem( + userInfo: 0, + title: Settings.locale((s) => s.copy, ctx: context), + textStyle: TextStyle(color: application.theme.fontLightColor, fontSize: 12), + )); + } + items.add(PopMenu.MenuItem( + userInfo: 100, + title: Settings.locale((s) => s.delete, ctx: context), + textStyle: TextStyle(color: application.theme.fontLightColor, fontSize: 12), + )); + if (_message.isOutbound) { + items.add(PopMenu.MenuItem( + userInfo: 101, + title: Settings.locale((s) => s.revoke, ctx: context), + textStyle: TextStyle(color: application.theme.fontLightColor, fontSize: 12), + )); + } + PopMenu.PopupMenu popupMenu = PopMenu.PopupMenu( + context: context, + items: items, + onClickMenu: (PopMenu.MenuItemProvider item) async { + var index = (item as PopMenu.MenuItem).userInfo; + switch (index) { + case 0: + Util.copyText(_message.content?.toString() ?? ""); + break; + case 100: + ModalDialog.of(Settings.appContext).confirm( + title: Settings.locale((s) => s.delete_message_confirm_title, ctx: context), + agree: Button( + width: double.infinity, + text: Settings.locale((s) => s.delete, ctx: context), + backgroundColor: application.theme.strongColor, + onPressed: () async { + await messageCommon.messageDelete(_message, notify: true); + if (Navigator.of(this.context).canPop()) Navigator.pop(this.context); + }, + ), + reject: Button( + width: double.infinity, + text: Settings.locale((s) => s.cancel, ctx: context), + fontColor: application.theme.fontColor2, + backgroundColor: application.theme.backgroundLightColor, + onPressed: () { + if (Navigator.of(this.context).canPop()) Navigator.pop(this.context); + }, + ), + ); + break; + case 101: + ModalDialog.of(Settings.appContext).confirm( + title: Settings.locale((s) => s.tip, ctx: context), + content: Settings.locale((s) => s.confirm_revoke, ctx: context), + agree: Button( + width: double.infinity, + text: Settings.locale((s) => s.revoke, ctx: context), + backgroundColor: application.theme.strongColor, + onPressed: () async { + bool ok = await chatOutCommon.sendRevoke(_message.msgId); + if (ok) await messageCommon.messageDelete(_message, notify: true); + if (Navigator.of(this.context).canPop()) Navigator.pop(this.context); + }, + ), + reject: Button( + width: double.infinity, + text: Settings.locale((s) => s.cancel, ctx: context), + fontColor: application.theme.fontColor2, + backgroundColor: application.theme.backgroundLightColor, + onPressed: () { + if (Navigator.of(this.context).canPop()) Navigator.pop(this.context); + }, + ), + ); + break; + } + }, + ); + popupMenu.show(widgetKey: _contentKey); + } + Widget _widgetBubbleInfoBottom() { Color color = _message.isOutbound ? application.theme.fontLightColor.withAlpha(178) : application.theme.fontColor2.withAlpha(178); diff --git a/lib/generated/intl/messages_en.dart b/lib/generated/intl/messages_en.dart index eb5e8f7fa..ea36f4ea0 100644 --- a/lib/generated/intl/messages_en.dart +++ b/lib/generated/intl/messages_en.dart @@ -147,6 +147,8 @@ class MessageLookup extends MessageLookupByLibrary { MessageLookupByLibrary.simpleMessage("Confirm Password"), "confirm_resend": MessageLookupByLibrary.simpleMessage("Confirm resend?"), + "confirm_revoke": MessageLookupByLibrary.simpleMessage( + "Are you sure you want to revoke this message?"), "confirm_unsubscribe_group": MessageLookupByLibrary.simpleMessage( "Are you sure you want to leave the group chat?"), "connect": MessageLookupByLibrary.simpleMessage("Connect"), @@ -504,6 +506,7 @@ class MessageLookup extends MessageLookupByLibrary { "rename": MessageLookupByLibrary.simpleMessage("Rename"), "request_processed": MessageLookupByLibrary.simpleMessage( "Requests still being processed, please try again later"), + "revoke": MessageLookupByLibrary.simpleMessage("Revoke"), "save": MessageLookupByLibrary.simpleMessage("Save"), "save_contact": MessageLookupByLibrary.simpleMessage("Save Contact"), "save_to_album": MessageLookupByLibrary.simpleMessage("Save To Album"), diff --git a/lib/generated/intl/messages_zh_CN.dart b/lib/generated/intl/messages_zh_CN.dart index 46f2775d3..e4c69e127 100644 --- a/lib/generated/intl/messages_zh_CN.dart +++ b/lib/generated/intl/messages_zh_CN.dart @@ -125,6 +125,7 @@ class MessageLookup extends MessageLookupByLibrary { MessageLookupByLibrary.simpleMessage("确定要删除备注头像吗?"), "confirm_password": MessageLookupByLibrary.simpleMessage("确认密码"), "confirm_resend": MessageLookupByLibrary.simpleMessage("确认重新发送?"), + "confirm_revoke": MessageLookupByLibrary.simpleMessage("确定要撤回此消息吗?"), "confirm_unsubscribe_group": MessageLookupByLibrary.simpleMessage("确定要退出群聊吗?"), "connect": MessageLookupByLibrary.simpleMessage("连接"), @@ -417,6 +418,7 @@ class MessageLookup extends MessageLookupByLibrary { "rename": MessageLookupByLibrary.simpleMessage("修改"), "request_processed": MessageLookupByLibrary.simpleMessage("还有正在处理的请求,请稍后再试"), + "revoke": MessageLookupByLibrary.simpleMessage("撤回"), "save": MessageLookupByLibrary.simpleMessage("保存"), "save_contact": MessageLookupByLibrary.simpleMessage("保存联系人"), "save_to_album": MessageLookupByLibrary.simpleMessage("保存到相册"), diff --git a/lib/generated/intl/messages_zh_TW.dart b/lib/generated/intl/messages_zh_TW.dart index ee433ea72..8ac3f474b 100644 --- a/lib/generated/intl/messages_zh_TW.dart +++ b/lib/generated/intl/messages_zh_TW.dart @@ -125,6 +125,7 @@ class MessageLookup extends MessageLookupByLibrary { MessageLookupByLibrary.simpleMessage("確定要刪除備註頭像嗎?"), "confirm_password": MessageLookupByLibrary.simpleMessage("確認密碼"), "confirm_resend": MessageLookupByLibrary.simpleMessage("確認重新發送?"), + "confirm_revoke": MessageLookupByLibrary.simpleMessage("確定要撤回此消息嗎?"), "confirm_unsubscribe_group": MessageLookupByLibrary.simpleMessage("確定要退出群聊嗎?"), "connect": MessageLookupByLibrary.simpleMessage("連接"), @@ -417,6 +418,7 @@ class MessageLookup extends MessageLookupByLibrary { "rename": MessageLookupByLibrary.simpleMessage("修改"), "request_processed": MessageLookupByLibrary.simpleMessage("還有正在處理的請求,請稍後再試"), + "revoke": MessageLookupByLibrary.simpleMessage("撤回"), "save": MessageLookupByLibrary.simpleMessage("保存"), "save_contact": MessageLookupByLibrary.simpleMessage("保存聯系人"), "save_to_album": MessageLookupByLibrary.simpleMessage("保存到相冊"), diff --git a/lib/generated/l10n.dart b/lib/generated/l10n.dart index 62bcc7331..2d3a730cc 100644 --- a/lib/generated/l10n.dart +++ b/lib/generated/l10n.dart @@ -4119,6 +4119,26 @@ class S { args: [], ); } + + /// `Revoke` + String get revoke { + return Intl.message( + 'Revoke', + name: 'revoke', + desc: '', + args: [], + ); + } + + /// `Are you sure you want to revoke this message?` + String get confirm_revoke { + return Intl.message( + 'Are you sure you want to revoke this message?', + name: 'confirm_revoke', + desc: '', + args: [], + ); + } } class AppLocalizationDelegate extends LocalizationsDelegate { diff --git a/lib/l10n/intl_en.arb b/lib/l10n/intl_en.arb index 85373cb46..d472d7d80 100644 --- a/lib/l10n/intl_en.arb +++ b/lib/l10n/intl_en.arb @@ -425,5 +425,7 @@ "developer_options": "developer options", "message_debug_info": "message debug information", "block": "Block", - "block_tips": "After enabling, messages from this user will be blocked." + "block_tips": "After enabling, messages from this user will be blocked.", + "revoke": "Revoke", + "confirm_revoke": "Are you sure you want to revoke this message?" } \ No newline at end of file diff --git a/lib/l10n/intl_zh_CN.arb b/lib/l10n/intl_zh_CN.arb index 21e110899..3317f7697 100644 --- a/lib/l10n/intl_zh_CN.arb +++ b/lib/l10n/intl_zh_CN.arb @@ -425,5 +425,7 @@ "developer_options": "开发人员选项", "message_debug_info": "消息调试信息", "block": "屏蔽", - "block_tips": "启用后,将屏蔽对方的消息。" + "block_tips": "启用后,将屏蔽对方的消息。", + "revoke": "撤回", + "confirm_revoke": "确定要撤回此消息吗?" } \ No newline at end of file diff --git a/lib/l10n/intl_zh_TW.arb b/lib/l10n/intl_zh_TW.arb index c500c5263..aa6ad1c45 100644 --- a/lib/l10n/intl_zh_TW.arb +++ b/lib/l10n/intl_zh_TW.arb @@ -425,5 +425,7 @@ "developer_options": "开发人员选项", "message_debug_info": "消息调试信息", "block": "屏蔽", - "block_tips": "啟用後,將封鎖對方的訊息。" + "block_tips": "啟用後,將封鎖對方的訊息。", + "revoke": "撤回", + "confirm_revoke": "確定要撤回此消息嗎?" } \ No newline at end of file diff --git a/lib/schema/message.dart b/lib/schema/message.dart index 31463bd5d..6c6505498 100644 --- a/lib/schema/message.dart +++ b/lib/schema/message.dart @@ -64,6 +64,9 @@ class MessageContentType { static const String privateGroupMemberRequest = 'privateGroup:memberRequest'; // . static const String privateGroupMemberResponse = 'privateGroup:memberResponse'; // . + // revoke message + static const String revoke = 'revoke'; + // SUPPORT:START static const String msgStatus = 'msgStatus'; // SUPPORT:END @@ -264,6 +267,9 @@ class MessageSchema { case MessageContentType.deviceInfo: schema.content = data; break; + case MessageContentType.revoke: + schema.content = data['targetId']; + break; case MessageContentType.ipfs: schema.content = null; break; @@ -441,6 +447,9 @@ class MessageSchema { case MessageContentType.piece: if (isContentFile) map['content'] = Path.convert2Local((content as File).path); break; + case MessageContentType.revoke: + map['content'] = content; + break; case MessageContentType.privateGroupInvitation: case MessageContentType.privateGroupAccept: case MessageContentType.privateGroupQuit: @@ -514,6 +523,9 @@ class MessageSchema { schema.content = e['content']; } break; + case MessageContentType.revoke: + schema.content = e['content']; + break; default: schema.content = e['content']; break; @@ -1226,6 +1238,14 @@ class MessageData { return jsonEncode(data); } + static String getRevoke(String targetMsgId) { + Map data = _base(MessageContentType.revoke); + data.addAll({ + 'targetId': targetMsgId, + }); + return jsonEncode(data); + } + static String getContactProfileRequest(String requestType, String? profileVersion) { Map data = _base(MessageSchema.supportContentType(MessageContentType.contactProfile)); data.addAll({ diff --git a/pubspec.yaml b/pubspec.yaml index aa4e26a60..a16287937 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -16,7 +16,7 @@ publish_to: 'none' # Remove this line if you wish to publish to pub.dev # Read more about iOS versioning at # https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html -version: 1.7.0+331 +version: 1.7.0+332 environment: sdk: ">=2.12.0 <3.0.0" From 7389d3272e45c73dc690b4468e14af479eca38b5 Mon Sep 17 00:00:00 2001 From: Heron Date: Thu, 11 Sep 2025 14:31:40 +0800 Subject: [PATCH 09/47] Remove about tab --- lib/screens/settings/home.dart | 36 ++-------------------------------- pubspec.yaml | 2 +- 2 files changed, 3 insertions(+), 35 deletions(-) diff --git a/lib/screens/settings/home.dart b/lib/screens/settings/home.dart index c7910c4d4..bdcdcb2c9 100644 --- a/lib/screens/settings/home.dart +++ b/lib/screens/settings/home.dart @@ -459,7 +459,7 @@ class _SettingsHomeScreenState extends BaseStateFulWidgetState[ @@ -481,39 +481,7 @@ class _SettingsHomeScreenState extends BaseStateFulWidgetState[ - Label( - Settings.locale((s) => s.contact_us, ctx: context), - type: LabelType.bodyRegular, - color: application.theme.fontColor1, - fontWeight: FontWeight.bold, - height: 1, - ), - Row( - children: [ - Label( - 'nmobile@nkn.org', - type: LabelType.bodyRegular, - color: application.theme.fontColor2, - height: 1, - ), - ], - ), - ], - ), - onPressed: () async { - Util.launchUrl('mailto:nmobile@nkn.org'); - }, - ), - ), + ], ), ), diff --git a/pubspec.yaml b/pubspec.yaml index a16287937..5851b08b5 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -16,7 +16,7 @@ publish_to: 'none' # Remove this line if you wish to publish to pub.dev # Read more about iOS versioning at # https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html -version: 1.7.0+332 +version: 1.7.0+333 environment: sdk: ">=2.12.0 <3.0.0" From b865a4226cfd1872943a332cf85bb9e7a7803e44 Mon Sep 17 00:00:00 2001 From: Heron Date: Thu, 11 Sep 2025 16:41:26 +0800 Subject: [PATCH 10/47] chat: replace custom popup with MenuAnchor --- lib/components/chat/bubble.dart | 285 +++++++++++------ lib/components/tip/popup_menu.dart | 476 ----------------------------- 2 files changed, 190 insertions(+), 571 deletions(-) delete mode 100644 lib/components/tip/popup_menu.dart diff --git a/lib/components/chat/bubble.dart b/lib/components/chat/bubble.dart index 0e3345748..ac30c8e2b 100644 --- a/lib/components/chat/bubble.dart +++ b/lib/components/chat/bubble.dart @@ -15,7 +15,6 @@ import 'package:nmobile/components/button/button_icon.dart'; import 'package:nmobile/components/dialog/modal.dart'; import 'package:nmobile/components/text/label.dart'; import 'package:nmobile/components/text/markdown.dart'; -import 'package:nmobile/components/tip/popup_menu.dart' as PopMenu; import 'package:nmobile/helpers/error.dart'; import 'package:nmobile/schema/message.dart'; import 'package:nmobile/screens/common/media.dart'; @@ -46,6 +45,8 @@ class ChatBubble extends BaseStateFulWidget { class _ChatBubbleState extends BaseStateFulWidgetState with Tag { GlobalKey _contentKey = GlobalKey(); + final MenuController _menuController = MenuController(); + static const double _menuMinWidth = 180; StreamSubscription? _onProgressStreamSubscription; StreamSubscription? _onPlayProgressSubscription; @@ -55,6 +56,7 @@ class _ChatBubbleState extends BaseStateFulWidgetState with Tag { double _fetchProgress = -1; double _playProgress = -1; String? _thumbnailPath; + Offset _menuAlignmentOffset = Offset.zero; @override void onRefreshArguments() { @@ -297,9 +299,7 @@ class _ChatBubbleState extends BaseStateFulWidgetState with Tag { } var onTap = _onTapBubble(contentType); - var onLongPress = () { - _showPopupMenu(includeCopy: contentType == MessageContentType.text || contentType == MessageContentType.textExtension); - }; + bool includeCopy = contentType == MessageContentType.text || contentType == MessageContentType.textExtension; List childs = [SizedBox.shrink()]; switch (contentType) { @@ -325,22 +325,40 @@ class _ChatBubbleState extends BaseStateFulWidgetState with Tag { break; } - return GestureDetector( - key: _contentKey, - onTap: onTap, - onLongPress: onLongPress, - child: Container( - constraints: BoxConstraints(maxWidth: maxWidth), - padding: EdgeInsets.only(left: 10, right: 10, top: 10, bottom: 5), - decoration: decoration, - child: Column( - crossAxisAlignment: CrossAxisAlignment.end, - children: [ - ...childs, - _widgetBubbleInfoBottom(), - ], - ), + return MenuAnchor( + controller: _menuController, + style: MenuStyle( + backgroundColor: MaterialStatePropertyAll(application.theme.backgroundColor4), + surfaceTintColor: MaterialStatePropertyAll(Colors.transparent), + elevation: MaterialStatePropertyAll(6), + shape: MaterialStatePropertyAll(RoundedRectangleBorder(borderRadius: BorderRadius.circular(8))), + padding: MaterialStatePropertyAll(EdgeInsets.zero), ), + alignmentOffset: _menuAlignmentOffset, + menuChildren: _buildMenuChildren(includeCopy), + builder: (context, anchorController, child) { + return Semantics( + label: 'chat_bubble', + button: true, + child: GestureDetector( + key: _contentKey, + behavior: HitTestBehavior.opaque, + onLongPressStart: (_) => _openMenu(), + child: Container( + constraints: BoxConstraints(maxWidth: maxWidth), + padding: EdgeInsets.only(left: 10, right: 10, top: 10, bottom: 5), + decoration: decoration, + child: Column( + crossAxisAlignment: CrossAxisAlignment.end, + children: [ + ...childs, + _widgetBubbleInfoBottom(), + ], + ), + ), + ), + ); + }, ); } @@ -349,7 +367,7 @@ class _ChatBubbleState extends BaseStateFulWidgetState with Tag { switch (contentType) { case MessageContentType.text: case MessageContentType.textExtension: - onTap = () => _showPopupMenu(includeCopy: true); + onTap = null; // Open menu via MenuAnchor break; case MessageContentType.image: // image + ipfs_image @@ -430,88 +448,165 @@ class _ChatBubbleState extends BaseStateFulWidgetState with Tag { return onTap; } - void _showPopupMenu({bool includeCopy = false}) { - List items = []; + List _buildMenuChildren(bool includeCopy) { + List children = []; + Widget buildItem({required String keyName, required String label, required IconData icon, required VoidCallback onPressed, Color? textColor, Color? iconColor}) { + return Semantics( + label: keyName, + button: true, + child: ConstrainedBox( + constraints: BoxConstraints(minWidth: _menuMinWidth), + child: MenuItemButton( + key: ValueKey(keyName), + onPressed: onPressed, + child: Row( + mainAxisSize: MainAxisSize.min, + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Flexible( + child: Text(label, style: TextStyle(color: textColor ?? application.theme.fontLightColor)), + ), + SizedBox(width: 12), + Icon(icon, size: 18, color: iconColor ?? application.theme.fontLightColor), + ], + ), + ), + ), + ); + } + void addDivider() { + children.add(Divider(height: 1, thickness: 0.5, color: application.theme.lineColor)); + } if (includeCopy) { - items.add(PopMenu.MenuItem( - userInfo: 0, - title: Settings.locale((s) => s.copy, ctx: context), - textStyle: TextStyle(color: application.theme.fontLightColor, fontSize: 12), + children.add(buildItem( + keyName: 'chat_bubble_menu_copy', + label: Settings.locale((s) => s.copy, ctx: context), + icon: Icons.copy_outlined, + onPressed: () { + Util.copyText(_message.content?.toString() ?? ""); + }, )); + addDivider(); } - items.add(PopMenu.MenuItem( - userInfo: 100, - title: Settings.locale((s) => s.delete, ctx: context), - textStyle: TextStyle(color: application.theme.fontLightColor, fontSize: 12), - )); + if (_message.isOutbound) { - items.add(PopMenu.MenuItem( - userInfo: 101, - title: Settings.locale((s) => s.revoke, ctx: context), - textStyle: TextStyle(color: application.theme.fontLightColor, fontSize: 12), + children.add(buildItem( + keyName: 'chat_bubble_menu_revoke', + label: Settings.locale((s) => s.revoke, ctx: context), + icon: Icons.undo, + onPressed: () { + ModalDialog.of(Settings.appContext).confirm( + title: Settings.locale((s) => s.tip, ctx: context), + content: Settings.locale((s) => s.confirm_revoke, ctx: context), + agree: Button( + width: double.infinity, + text: Settings.locale((s) => s.revoke, ctx: context), + backgroundColor: application.theme.strongColor, + onPressed: () async { + bool ok = await chatOutCommon.sendRevoke(_message.msgId); + if (ok) await messageCommon.messageDelete(_message, notify: true); + if (Navigator.of(this.context).canPop()) Navigator.pop(this.context); + }, + ), + reject: Button( + width: double.infinity, + text: Settings.locale((s) => s.cancel, ctx: context), + fontColor: application.theme.fontColor2, + backgroundColor: application.theme.backgroundLightColor, + onPressed: () { + if (Navigator.of(this.context).canPop()) Navigator.pop(this.context); + }, + ), + ); + }, )); + addDivider(); } - PopMenu.PopupMenu popupMenu = PopMenu.PopupMenu( - context: context, - items: items, - onClickMenu: (PopMenu.MenuItemProvider item) async { - var index = (item as PopMenu.MenuItem).userInfo; - switch (index) { - case 0: - Util.copyText(_message.content?.toString() ?? ""); - break; - case 100: - ModalDialog.of(Settings.appContext).confirm( - title: Settings.locale((s) => s.delete_message_confirm_title, ctx: context), - agree: Button( - width: double.infinity, - text: Settings.locale((s) => s.delete, ctx: context), - backgroundColor: application.theme.strongColor, - onPressed: () async { - await messageCommon.messageDelete(_message, notify: true); - if (Navigator.of(this.context).canPop()) Navigator.pop(this.context); - }, - ), - reject: Button( - width: double.infinity, - text: Settings.locale((s) => s.cancel, ctx: context), - fontColor: application.theme.fontColor2, - backgroundColor: application.theme.backgroundLightColor, - onPressed: () { - if (Navigator.of(this.context).canPop()) Navigator.pop(this.context); - }, - ), - ); - break; - case 101: - ModalDialog.of(Settings.appContext).confirm( - title: Settings.locale((s) => s.tip, ctx: context), - content: Settings.locale((s) => s.confirm_revoke, ctx: context), - agree: Button( - width: double.infinity, - text: Settings.locale((s) => s.revoke, ctx: context), - backgroundColor: application.theme.strongColor, - onPressed: () async { - bool ok = await chatOutCommon.sendRevoke(_message.msgId); - if (ok) await messageCommon.messageDelete(_message, notify: true); - if (Navigator.of(this.context).canPop()) Navigator.pop(this.context); - }, - ), - reject: Button( - width: double.infinity, - text: Settings.locale((s) => s.cancel, ctx: context), - fontColor: application.theme.fontColor2, - backgroundColor: application.theme.backgroundLightColor, - onPressed: () { - if (Navigator.of(this.context).canPop()) Navigator.pop(this.context); - }, - ), - ); - break; - } + children.add(buildItem( + keyName: 'chat_bubble_menu_delete', + label: Settings.locale((s) => s.delete, ctx: context), + icon: Icons.delete_outline, + textColor: application.theme.fallColor, + iconColor: application.theme.fallColor, + onPressed: () { + ModalDialog.of(Settings.appContext).confirm( + title: Settings.locale((s) => s.delete_message_confirm_title, ctx: context), + agree: Button( + width: double.infinity, + text: Settings.locale((s) => s.delete, ctx: context), + backgroundColor: application.theme.strongColor, + onPressed: () async { + await messageCommon.messageDelete(_message, notify: true); + if (Navigator.of(this.context).canPop()) Navigator.pop(this.context); + }, + ), + reject: Button( + width: double.infinity, + text: Settings.locale((s) => s.cancel, ctx: context), + fontColor: application.theme.fontColor2, + backgroundColor: application.theme.backgroundLightColor, + onPressed: () { + if (Navigator.of(this.context).canPop()) Navigator.pop(this.context); + }, + ), + ); }, - ); - popupMenu.show(widgetKey: _contentKey); + )); + return children; + } + + void _openMenu() { + if (!mounted) return; + try { + RenderBox? box = _contentKey.currentContext?.findRenderObject() as RenderBox?; + if (box != null) { + final Offset global = box.localToGlobal(Offset.zero); + final double anchorTop = global.dy; + final double anchorHeight = box.size.height; + final double anchorBottom = anchorTop + anchorHeight; + double visibleTop; + double visibleBottom; + final scrollable = Scrollable.of(context); + if (scrollable != null) { + final RenderBox? vpBox = scrollable.context.findRenderObject() as RenderBox?; + final double vpTop = vpBox?.localToGlobal(Offset.zero).dy ?? 0; + final double vpBottom = vpTop + scrollable.position.viewportDimension; + visibleTop = vpTop; + visibleBottom = vpBottom; + } else { + final media = MediaQuery.of(context); + visibleTop = media.padding.top; + visibleBottom = media.size.height - media.viewInsets.bottom; + } + + // Estimate menu height + final int itemsCount = 1 /*delete*/ + (_message.isOutbound ? 1 : 0) + ((_message.contentType == MessageContentType.text || _message.contentType == MessageContentType.textExtension) ? 1 : 0); + final double estimatedMenuHeight = itemsCount * 48.0; + final double gap = 8.0; + final double spaceBelow = visibleBottom - anchorBottom; + final double spaceAbove = anchorTop - visibleTop; + + Offset nextOffset = Offset.zero; + final double needed = estimatedMenuHeight + gap; + if (spaceBelow < needed) { + if (spaceAbove >= needed) { + nextOffset = Offset(0, -needed); + } else { + nextOffset = Offset(0, -max(0.0, spaceAbove - gap)); + } + } + if (nextOffset != _menuAlignmentOffset) { + setState(() { + _menuAlignmentOffset = nextOffset; + }); + } + } + } catch (_) { + // ignore + } + if (!_menuController.isOpen) { + _menuController.open(); + } } Widget _widgetBubbleInfoBottom() { diff --git a/lib/components/tip/popup_menu.dart b/lib/components/tip/popup_menu.dart deleted file mode 100644 index c32509dbe..000000000 --- a/lib/components/tip/popup_menu.dart +++ /dev/null @@ -1,476 +0,0 @@ -import 'dart:core'; -import 'dart:math'; -import 'dart:ui'; - -import 'package:flutter/material.dart'; -import 'package:flutter/rendering.dart'; - -class TrianglePainter extends CustomPainter { - bool isDown; - Color color; - - TrianglePainter({this.isDown = true, required this.color}); - - @override - void paint(Canvas canvas, Size size) { - Paint _paint = new Paint(); - _paint.strokeWidth = 2.0; - _paint.color = color; - _paint.style = PaintingStyle.fill; - - Path path = new Path(); - if (isDown) { - path.moveTo(0.0, -1.0); - path.lineTo(size.width, -1.0); - path.lineTo(size.width / 2.0, size.height); - } else { - path.moveTo(size.width / 2.0, 0.0); - path.lineTo(0.0, size.height + 1); - path.lineTo(size.width, size.height + 1); - } - - canvas.drawPath(path, _paint); - } - - @override - bool shouldRepaint(CustomPainter oldDelegate) { - return true; - } -} - -abstract class MenuItemProvider { - String? get menuTitle; - Widget? get menuImage; - TextStyle? get menuTextStyle; -} - -class MenuItem extends MenuItemProvider { - Widget? image; - String? title; - dynamic userInfo; - TextStyle? textStyle; - - MenuItem({this.title, this.image, this.userInfo, this.textStyle}); - - @override - Widget? get menuImage => image; - - @override - String? get menuTitle => title; - - @override - TextStyle get menuTextStyle => textStyle ?? TextStyle(color: Color(0xffc5c5c5), fontSize: 10.0); -} - -enum MenuType { big, oneLine } - -typedef MenuClickCallback = Function(MenuItemProvider item); -typedef PopupMenuStateChanged = Function(bool isShow); - -class PopupMenu { - static var itemWidth = 68.0; - static var itemHeight = 36.0; - static var arrowHeight = 10.0; - - OverlayEntry? _entry; - late List items; - - /// row count - int _row = 1; - - /// col count - int _col = 1; - - /// The left top point of this menu. - late Offset _offset; - - /// Menu will show at above or under this rect - late Rect _showRect; - - /// if false menu is show above of the widget, otherwise menu is show under the widget - bool _isDown = true; - - /// The max column count, default is 4. - int _maxColumn = 4; - - /// callback - VoidCallback? dismissCallback; - MenuClickCallback? onClickMenu; - PopupMenuStateChanged? stateChanged; - - late Size _screenSize; // 屏幕的尺寸 - - /// Cannot be null - late BuildContext context; - - /// style - late Color _backgroundColor; - late Color _highlightColor; - late Color _lineColor; - - /// It's showing or not. - bool _isShow = false; - bool get isShow => _isShow; - - PopupMenu({ - required BuildContext context, - required List items, - MenuClickCallback? onClickMenu, - VoidCallback? onDismiss, - int? maxColumn, - Color? backgroundColor, - Color? highlightColor, - Color? lineColor, - PopupMenuStateChanged? stateChanged, - }) { - this.context = context; - this.onClickMenu = onClickMenu; - this.dismissCallback = onDismiss; - this.stateChanged = stateChanged; - this.items = items; - this._maxColumn = maxColumn ?? 4; - this._backgroundColor = backgroundColor ?? Color(0xff232323); - this._lineColor = lineColor ?? Color(0xff353535); - this._highlightColor = highlightColor ?? Color(0x55000000); - } - - void show({Rect? rect, GlobalKey? widgetKey, List? items}) { - if (rect == null && widgetKey == null) return; - - this.items = items ?? this.items; - this._showRect = rect ?? PopupMenu.getWidgetGlobalRect(widgetKey!) ?? Rect.zero; - this._screenSize = window.physicalSize / window.devicePixelRatio; - this.dismissCallback = dismissCallback; - - _calculatePosition(this.context); - - _entry = OverlayEntry(builder: (context) { - return buildPopupMenuLayout(_offset); - }); - - Overlay.of(this.context)?.insert(_entry!); - _isShow = true; - this.stateChanged?.call(true); - } - - static Rect? getWidgetGlobalRect(GlobalKey key) { - RenderObject? object = key.currentContext?.findRenderObject(); - if (!(object is RenderBox)) return null; - var offset = object.localToGlobal(Offset.zero); - return Rect.fromLTWH(offset.dx, offset.dy, object.size.width, object.size.height); - } - - void _calculatePosition(BuildContext context) { - _col = _calculateColCount(); - _row = _calculateRowCount(); - _offset = _calculateOffset(this.context); - } - - Offset _calculateOffset(BuildContext context) { - double dx = _showRect.left + _showRect.width / 2.0 - menuWidth() / 2.0; - if (dx < 10.0) { - dx = 10.0; - } - - if (dx + menuWidth() > _screenSize.width && dx > 10.0) { - double tempDx = _screenSize.width - menuWidth() - 10; - if (tempDx > 10) dx = tempDx; - } - - double dy = _showRect.top - menuHeight(); - if (dy <= MediaQuery.of(context).padding.top + 10) { - // The have not enough space above, show menu under the widget. - dy = arrowHeight + _showRect.height + _showRect.top; - _isDown = false; - } else { - dy -= arrowHeight; - _isDown = true; - } - - return Offset(dx, dy); - } - - double menuWidth() { - return itemWidth * _col; - } - - // This height exclude the arrow - double menuHeight() { - return itemHeight * _row; - } - - LayoutBuilder buildPopupMenuLayout(Offset offset) { - return LayoutBuilder(builder: (context, constraints) { - return GestureDetector( - behavior: HitTestBehavior.translucent, - onTap: () { - dismiss(); - }, -// onTapDown: (TapDownDetails details) { -// dismiss(); -// }, - // onPanStart: (DragStartDetails details) { - // dismiss(); - // }, - onVerticalDragStart: (DragStartDetails details) { - dismiss(); - }, - onHorizontalDragStart: (DragStartDetails details) { - dismiss(); - }, - child: Container( - child: Stack( - children: [ - // triangle arrow - Positioned( - left: _showRect.left + _showRect.width / 2.0 - 7.5, - top: _isDown ? offset.dy + menuHeight() : offset.dy - arrowHeight, - child: CustomPaint( - size: Size(15.0, arrowHeight), - painter: TrianglePainter(isDown: _isDown, color: _backgroundColor), - ), - ), - // menu content - Positioned( - left: offset.dx, - top: offset.dy, - child: Container( - width: menuWidth(), - height: menuHeight(), - child: Column( - children: [ - ClipRRect( - borderRadius: BorderRadius.circular(10.0), - child: Container( - width: menuWidth(), - height: menuHeight(), - decoration: BoxDecoration(color: _backgroundColor, borderRadius: BorderRadius.circular(10.0)), - child: Column( - children: _createRows(), - ), - )), - ], - ), - ), - ) - ], - ), - ), - ); - }); - } - - // 创建行 - List _createRows() { - List rows = []; - for (int i = 0; i < _row; i++) { - Color color = (i < _row - 1 && _row != 1) ? _lineColor : Colors.transparent; - Widget rowWidget = Container( - decoration: BoxDecoration(border: Border(bottom: BorderSide(color: color))), - height: itemHeight, - child: Row( - children: _createRowItems(i), - ), - ); - - rows.add(rowWidget); - } - - return rows; - } - - // 创建一行的item, row 从0开始算 - List _createRowItems(int row) { - List subItems = items.sublist(row * _col, min(row * _col + _col, items.length)); - List itemWidgets = []; - int i = 0; - for (var item in subItems) { - itemWidgets.add(_createMenuItem( - item, - i < (_col - 1), - )); - i++; - } - - return itemWidgets; - } - - // calculate row count - int _calculateRowCount() { - if (items.isEmpty) return 0; - - int itemCount = items.length; - - if (_calculateColCount() == 1) { - return itemCount; - } - - int row = (itemCount - 1) ~/ _calculateColCount() + 1; - - return row; - } - - // calculate col count - int _calculateColCount() { - if (items.isEmpty) return 0; - - int itemCount = items.length; - if (_maxColumn != 4 && _maxColumn > 0) { - return _maxColumn; - } - - if (itemCount == 4) { - // 4个显示成两行 - return 2; - } - - if (itemCount <= _maxColumn) { - return itemCount; - } - - if (itemCount == 5) { - return 3; - } - - if (itemCount == 6) { - return 3; - } - - return _maxColumn; - } - - double get screenWidth { - double width = window.physicalSize.width; - double ratio = window.devicePixelRatio; - return width / ratio; - } - - Widget _createMenuItem(MenuItemProvider item, bool showLine) { - return _MenuItemWidget( - item: item, - showLine: showLine, - clickCallback: itemClicked, - lineColor: _lineColor, - backgroundColor: _backgroundColor, - highlightColor: _highlightColor, - ); - } - - void itemClicked(MenuItemProvider item) { - onClickMenu?.call(item); - dismiss(); - } - - void dismiss() { - if (!_isShow) { - // Remove method should only be called once - return; - } - - _entry?.remove(); - _isShow = false; - dismissCallback?.call(); - this.stateChanged?.call(false); - } -} - -class _MenuItemWidget extends StatefulWidget { - final MenuItemProvider item; - final Color lineColor; - final Color backgroundColor; - final Color highlightColor; - final bool showLine; - final Function(MenuItemProvider item)? clickCallback; - - _MenuItemWidget({ - required this.item, - required this.lineColor, - required this.backgroundColor, - required this.highlightColor, - this.showLine = false, - this.clickCallback, - }); - - @override - State createState() { - return _MenuItemWidgetState(); - } -} - -class _MenuItemWidgetState extends State<_MenuItemWidget> { - var highlightColor = Color(0x55000000); - var color = Color(0xff232323); - - @override - void initState() { - color = widget.backgroundColor; - highlightColor = widget.highlightColor; - super.initState(); - } - - @override - Widget build(BuildContext context) { - return GestureDetector( - onTapDown: (details) { - color = highlightColor; - setState(() {}); - }, - onTapUp: (details) { - color = widget.backgroundColor; - setState(() {}); - }, - onLongPressEnd: (details) { - color = widget.backgroundColor; - setState(() {}); - }, - onTap: () { - widget.clickCallback?.call(widget.item); - }, - child: Container( - width: PopupMenu.itemWidth, - height: PopupMenu.itemHeight, - decoration: BoxDecoration(color: color, border: Border(right: BorderSide(color: widget.showLine ? widget.lineColor : Colors.transparent))), - child: _createContent(), - ), - ); - } - - Widget _createContent() { - if (widget.item.menuImage != null) { - // image and text - return Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Container( - width: 30.0, - height: 30.0, - child: widget.item.menuImage, - ), - Container( - height: 22.0, - child: Material( - color: Colors.transparent, - child: Text( - widget.item.menuTitle ?? " ", - style: widget.item.menuTextStyle, - ), - ), - ) - ], - ); - } else { - // only text - return Container( - child: Center( - child: Material( - color: Colors.transparent, - child: Text( - widget.item.menuTitle ?? " ", - style: widget.item.menuTextStyle, - ), - ), - ), - ); - } - } -} From 308532947a1183ea9de2bd31ecec489dc8d5614b Mon Sep 17 00:00:00 2001 From: Heron Date: Thu, 11 Sep 2025 16:48:18 +0800 Subject: [PATCH 11/47] profile: modifies styles --- lib/screens/contact/profile.dart | 55 ++++++++++++++++---------------- 1 file changed, 27 insertions(+), 28 deletions(-) diff --git a/lib/screens/contact/profile.dart b/lib/screens/contact/profile.dart index 3abfbbaf0..f8aa2d85e 100644 --- a/lib/screens/contact/profile.dart +++ b/lib/screens/contact/profile.dart @@ -852,6 +852,33 @@ class _ContactProfileScreenState extends BaseStateFulWidgetState[ + Asset.iconSvg('chat', color: application.theme.primaryColor, width: 24), + SizedBox(width: 10), + Label( + Settings.locale((s) => s.send_message, ctx: context), + type: LabelType.bodyRegular, + color: application.theme.fontColor1, + ), + Spacer(), + Asset.iconSvg( + 'right', + width: 24, + color: application.theme.fontColor2, + ), + ], + ), + ), + SizedBox(height: 28), + /// burn TextButton( style: _buttonStyle(topRadius: true, botRadius: true, topPad: 8, botPad: 8), @@ -1028,34 +1055,6 @@ class _ContactProfileScreenState extends BaseStateFulWidgetState[ - Asset.iconSvg('chat', color: application.theme.primaryColor, width: 24), - SizedBox(width: 10), - Label( - Settings.locale((s) => s.send_message, ctx: context), - type: LabelType.bodyRegular, - color: application.theme.fontColor1, - ), - Spacer(), - Asset.iconSvg( - 'right', - width: 24, - color: application.theme.fontColor2, - ), - ], - ), - ), - // SizedBox(height: 28), /// AddContact _contact?.type != ContactType.friend From 27df7fd608aede78442855ddb010be398065f42c Mon Sep 17 00:00:00 2001 From: Heron Date: Thu, 11 Sep 2025 18:23:49 +0800 Subject: [PATCH 12/47] chat: add emoji picker --- ios/Podfile.lock | 6 +++ lib/components/chat/bubble.dart | 1 + lib/components/chat/emoji_picker.dart | 60 +++++++++++++++++++++++++++ lib/components/chat/send_bar.dart | 52 ++++++++++++++++------- lib/screens/chat/messages.dart | 25 ++++++++++- pubspec.lock | 8 ++++ pubspec.yaml | 1 + 7 files changed, 137 insertions(+), 16 deletions(-) create mode 100644 lib/components/chat/emoji_picker.dart diff --git a/ios/Podfile.lock b/ios/Podfile.lock index df29cee38..23f167dce 100644 --- a/ios/Podfile.lock +++ b/ios/Podfile.lock @@ -36,6 +36,8 @@ PODS: - DKPhotoGallery/Resource (0.0.19): - SDWebImage - SwiftyGif + - emoji_picker_flutter (0.0.1): + - Flutter - file_picker (0.0.1): - DKImagePickerController/PhotoGallery - Flutter @@ -243,6 +245,7 @@ DEPENDENCIES: - audio_session (from `.symlinks/plugins/audio_session/ios`) - connectivity_plus (from `.symlinks/plugins/connectivity_plus/ios`) - device_info_plus (from `.symlinks/plugins/device_info_plus/ios`) + - emoji_picker_flutter (from `.symlinks/plugins/emoji_picker_flutter/ios`) - file_picker (from `.symlinks/plugins/file_picker/ios`) - Firebase/Analytics - Flutter (from `Flutter`) @@ -306,6 +309,8 @@ EXTERNAL SOURCES: :path: ".symlinks/plugins/connectivity_plus/ios" device_info_plus: :path: ".symlinks/plugins/device_info_plus/ios" + emoji_picker_flutter: + :path: ".symlinks/plugins/emoji_picker_flutter/ios" file_picker: :path: ".symlinks/plugins/file_picker/ios" Flutter: @@ -369,6 +374,7 @@ SPEC CHECKSUMS: device_info_plus: 21fcca2080fbcd348be798aa36c3e5ed849eefbe DKImagePickerController: 946cec48c7873164274ecc4624d19e3da4c1ef3c DKPhotoGallery: b3834fecb755ee09a593d7c9e389d8b5d6deed60 + emoji_picker_flutter: ece213fc274bdddefb77d502d33080dc54e616cc file_picker: a0560bc09d61de87f12d246fc47d2119e6ef37be Firebase: d99ac19b909cd2c548339c2241ecd0d1599ab02e FirebaseAnalytics: 6433dfd311ba78084fc93bdfc145e8cb75740eae diff --git a/lib/components/chat/bubble.dart b/lib/components/chat/bubble.dart index ac30c8e2b..3c13a1b71 100644 --- a/lib/components/chat/bubble.dart +++ b/lib/components/chat/bubble.dart @@ -343,6 +343,7 @@ class _ChatBubbleState extends BaseStateFulWidgetState with Tag { child: GestureDetector( key: _contentKey, behavior: HitTestBehavior.opaque, + onTap: onTap, onLongPressStart: (_) => _openMenu(), child: Container( constraints: BoxConstraints(maxWidth: maxWidth), diff --git a/lib/components/chat/emoji_picker.dart b/lib/components/chat/emoji_picker.dart new file mode 100644 index 000000000..76b5ab4be --- /dev/null +++ b/lib/components/chat/emoji_picker.dart @@ -0,0 +1,60 @@ +import 'package:emoji_picker_flutter/emoji_picker_flutter.dart'; +import 'package:flutter/cupertino.dart'; +import 'package:flutter/foundation.dart' as foundation; +import 'package:flutter/material.dart'; +import 'package:nmobile/common/locator.dart'; +import 'package:nmobile/common/settings.dart'; +import 'package:nmobile/components/layout/expansion_layout.dart'; + +class EmojiPickerBottomMenu extends StatelessWidget { + final String? target; + final bool show; + final Function(List> result)? onPicked; + final TextEditingController controller; + final _scrollController = ScrollController(); + + EmojiPickerBottomMenu({ + this.target, + this.show = false, + this.onPicked, + required this.controller, + }); + + @override + Widget build(BuildContext context) { + double btnSize = Settings.screenWidth() / 6; + double iconSize = btnSize / 2; + + return ExpansionLayout( + isExpanded: show, + child: Container( + decoration: BoxDecoration( + border: Border( + top: BorderSide(color: application.theme.backgroundColor2), + ), + ), + child: EmojiPicker( + textEditingController: controller, + scrollController: _scrollController, + config: Config( + height: 256, + checkPlatformCompatibility: true, + viewOrderConfig: const ViewOrderConfig(), + emojiViewConfig: EmojiViewConfig( + // Issue: https://github.com/flutter/flutter/issues/28894 + emojiSizeMax: 28 * + (foundation.defaultTargetPlatform == + TargetPlatform.iOS + ? 1.2 + : 1.0), + ), + skinToneConfig: const SkinToneConfig(), + categoryViewConfig: const CategoryViewConfig(), + bottomActionBarConfig: const BottomActionBarConfig(), + searchViewConfig: const SearchViewConfig(), + ), + ), + ), + ); + } +} diff --git a/lib/components/chat/send_bar.dart b/lib/components/chat/send_bar.dart index a2a927b93..21d3ae9fe 100644 --- a/lib/components/chat/send_bar.dart +++ b/lib/components/chat/send_bar.dart @@ -20,22 +20,26 @@ class ChatSendBar extends BaseStateFulWidget { final String? targetId; final String? disableTip; final VoidCallback? onMenuPressed; + final VoidCallback? onEmojiPressed; final Function(String)? onSendPress; final Function(bool)? onInputFocus; final Function(bool, bool, int)? onRecordTap; final Function(bool, bool)? onRecordLock; final Stream>? onChangeStream; + final TextEditingController? controller; ChatSendBar({ Key? key, required this.targetId, this.disableTip, this.onMenuPressed, + this.onEmojiPressed, this.onSendPress, this.onInputFocus, this.onRecordTap, this.onRecordLock, this.onChangeStream, + this.controller, }) : super(key: key); @override @@ -48,8 +52,9 @@ class _ChatSendBarState extends BaseStateFulWidgetState { StreamSubscription? _onChangeSubscription; StreamSubscription? _onRecordProgressSubscription; - TextEditingController _inputController = TextEditingController(); + late TextEditingController _inputController; FocusNode _inputFocusNode = FocusNode(); + VoidCallback? _inputControllerListener; String? _draft; bool _canSendText = false; @@ -66,7 +71,22 @@ class _ChatSendBarState extends BaseStateFulWidgetState { @override void initState() { super.initState(); + _inputController = widget.controller ?? TextEditingController(); // input + _inputControllerListener = () { + String draft = _inputController.text; + if (draft.isNotEmpty) { + memoryCache.setDraft(widget.targetId, draft); + } else { + memoryCache.removeDraft(widget.targetId); + } + if (mounted) { + setState(() { + _canSendText = draft.isNotEmpty; + }); + } + }; + _inputController.addListener(_inputControllerListener!); _onChangeSubscription = widget.onChangeStream?.listen((event) { String? type = event["type"]; if (type == null || type.isEmpty) return; @@ -75,9 +95,6 @@ class _ChatSendBarState extends BaseStateFulWidgetState { } else if (type == ChatSendBar.ChangeTypeAppend) { _inputController.text += event["content"] ?? ""; } - setState(() { - _canSendText = _inputController.text.isNotEmpty; - }); }); _inputFocusNode.addListener(() { widget.onInputFocus?.call(_inputFocusNode.hasFocus); @@ -118,6 +135,9 @@ class _ChatSendBarState extends BaseStateFulWidgetState { void dispose() { _onRecordProgressSubscription?.cancel(); _onChangeSubscription?.cancel(); + if (_inputControllerListener != null) { + _inputController.removeListener(_inputControllerListener!); + } super.dispose(); } @@ -369,6 +389,18 @@ class _ChatSendBarState extends BaseStateFulWidgetState { hintText: Settings.locale((s) => s.type_a_message, ctx: context), hintStyle: TextStyle(color: _theme.fontColor2), contentPadding: EdgeInsets.symmetric(vertical: 8, horizontal: 12), + prefixIcon: GestureDetector( + onTap: widget.onEmojiPressed, + behavior: HitTestBehavior.opaque, + child: Padding( + padding: EdgeInsets.only(left: 8, top: 4), + child: FaIcon( + FontAwesomeIcons.faceSmile, + color: application.theme.primaryColor, + ), + ), + ), + prefixIconConstraints: BoxConstraints(minHeight: 32, minWidth: 32), border: UnderlineInputBorder( borderRadius: BorderRadius.all(Radius.circular(20)), borderSide: const BorderSide(width: 0, style: BorderStyle.none), @@ -379,17 +411,7 @@ class _ChatSendBarState extends BaseStateFulWidgetState { controller: _inputController, focusNode: _inputFocusNode, textInputAction: TextInputAction.newline, - onChanged: (val) { - String draft = _inputController.text; - if (draft.isNotEmpty) { - memoryCache.setDraft(widget.targetId, draft); - } else { - memoryCache.removeDraft(widget.targetId); - } - setState(() { - _canSendText = val.isNotEmpty; - }); - }, + onChanged: null, ), ), ], diff --git a/lib/screens/chat/messages.dart b/lib/screens/chat/messages.dart index b150d3cd9..1c92ab9a7 100644 --- a/lib/screens/chat/messages.dart +++ b/lib/screens/chat/messages.dart @@ -41,6 +41,8 @@ import 'package:nmobile/utils/parallel_queue.dart'; import 'package:nmobile/utils/path.dart' as Path2; import 'package:nmobile/utils/time.dart'; +import '../../components/chat/emoji_picker.dart'; + class ChatMessagesScreen extends BaseStateFulWidget { static const String routeName = '/chat/messages'; static final String argTarget = "target"; @@ -103,10 +105,13 @@ class _ChatMessagesScreenState extends BaseStateFulWidgetState Date: Thu, 11 Sep 2025 18:24:27 +0800 Subject: [PATCH 13/47] Update version --- pubspec.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pubspec.yaml b/pubspec.yaml index bdd23a346..18ec67166 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -16,7 +16,7 @@ publish_to: 'none' # Remove this line if you wish to publish to pub.dev # Read more about iOS versioning at # https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html -version: 1.7.0+333 +version: 1.7.0+334 environment: sdk: ">=2.12.0 <3.0.0" From da9bd1e5236f1116c2d27e1eeaa9ce6890a2779b Mon Sep 17 00:00:00 2001 From: Heron Date: Fri, 12 Sep 2025 17:17:59 +0800 Subject: [PATCH 14/47] bug fix --- ios/Runner.xcodeproj/project.pbxproj | 6 +- lib/common/client/client.dart | 2 +- lib/main.dart | 5 +- lib/providers/connected_provider.dart | 15 ++ lib/screens/chat/home.dart | 159 ++++++++------- lib/screens/contact/profile.dart | 21 +- pubspec.lock | 283 +++++++++++++++++++++++++- pubspec.yaml | 3 +- 8 files changed, 399 insertions(+), 95 deletions(-) create mode 100644 lib/providers/connected_provider.dart diff --git a/ios/Runner.xcodeproj/project.pbxproj b/ios/Runner.xcodeproj/project.pbxproj index 37a0ca386..355a5b949 100644 --- a/ios/Runner.xcodeproj/project.pbxproj +++ b/ios/Runner.xcodeproj/project.pbxproj @@ -829,7 +829,7 @@ CODE_SIGN_ENTITLEMENTS = Runner/Runner.entitlements; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 332; + CURRENT_PROJECT_VERSION = 335; DEVELOPMENT_TEAM = 67P82ZQDAS; ENABLE_BITCODE = NO; INFOPLIST_FILE = Runner/Info.plist; @@ -966,7 +966,7 @@ CODE_SIGN_ENTITLEMENTS = Runner/Runner.entitlements; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 332; + CURRENT_PROJECT_VERSION = 335; DEVELOPMENT_TEAM = 67P82ZQDAS; ENABLE_BITCODE = NO; INFOPLIST_FILE = Runner/Info.plist; @@ -995,7 +995,7 @@ CODE_SIGN_ENTITLEMENTS = Runner/Runner.entitlements; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 332; + CURRENT_PROJECT_VERSION = 335; DEVELOPMENT_TEAM = 67P82ZQDAS; ENABLE_BITCODE = NO; INFOPLIST_FILE = Runner/Info.plist; diff --git a/lib/common/client/client.dart b/lib/common/client/client.dart index ef84d162e..05b368a4e 100644 --- a/lib/common/client/client.dart +++ b/lib/common/client/client.dart @@ -340,7 +340,7 @@ class ClientCommon with Tag { await chatInCommon.waitReceiveQueues("_signOut"); // wait db_insert from onMessage await chatInCommon.pause(reset: closeDB); client = null; - if (clearWallet) BlocProvider.of(Settings.appContext).add(DefaultWallet(null)); + if (closeDB) await dbCommon.close(); return true; } catch (e, st) { diff --git a/lib/main.dart b/lib/main.dart index 0ff1a487e..a7550cdc1 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -24,6 +24,7 @@ import 'package:nmobile/storages/settings.dart' as settings_storage; import 'package:nmobile/storages/wallet.dart'; import 'package:nmobile/utils/logger.dart'; import 'package:sentry_flutter/sentry_flutter.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; void main() async { WidgetsFlutterBinding.ensureInitialized(); @@ -108,11 +109,11 @@ void main() async { options.enableUserInteractionBreadcrumbs = true; // options.beforeSend = (SentryEvent event, {Hint? hint}) {}; }, - appRunner: () => runApp(Main()), + appRunner: () => runApp(ProviderScope(child: Main())), ); } else { catchGlobalError(() async { - runApp(Main()); + runApp(ProviderScope(child: Main())); }, onZoneError: (Object error, StackTrace stack) { if (Settings.debug) logger.e(error); if (Settings.sentryEnable) Sentry.captureException(error, stackTrace: stack); diff --git a/lib/providers/connected_provider.dart b/lib/providers/connected_provider.dart new file mode 100644 index 000000000..3947941b2 --- /dev/null +++ b/lib/providers/connected_provider.dart @@ -0,0 +1,15 @@ +import 'package:flutter_riverpod/legacy.dart'; + +class ConnectedNotifier extends StateNotifier { + ConnectedNotifier() : super(false); + + void setConnected(bool value) { + if (state != value) state = value; + } +} + +final connectedProvider = StateNotifierProvider((ref) { + return ConnectedNotifier(); +}); + + diff --git a/lib/screens/chat/home.dart b/lib/screens/chat/home.dart index 8ed766830..ea7e4269f 100644 --- a/lib/screens/chat/home.dart +++ b/lib/screens/chat/home.dart @@ -19,6 +19,8 @@ import 'package:nmobile/components/layout/header.dart'; import 'package:nmobile/components/layout/layout.dart'; import 'package:nmobile/components/text/label.dart'; import 'package:nmobile/routes/routes.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:nmobile/providers/connected_provider.dart'; import 'package:nmobile/schema/contact.dart'; import 'package:nmobile/schema/wallet.dart'; import 'package:nmobile/screens/chat/messages.dart'; @@ -55,7 +57,7 @@ class _ChatHomeScreenState extends BaseStateFulWidgetState with int clientConnectStatus = ClientConnectStatus.connecting; bool isLoginProgress = false; - bool connected = false; + // connected state managed by Riverpod connectedProvider @override void onRefreshArguments() {} @@ -165,12 +167,10 @@ class _ChatHomeScreenState extends BaseStateFulWidgetState with } _setConnected(bool show) { - if (connected != show) { - connected = show; // no check mounted - setState(() { - connected = show; - }); - } + try { + final container = ProviderScope.containerOf(context, listen: false); + container.read(connectedProvider.notifier).setConnected(show); + } catch (_) {} } _refreshContactMe({bool deviceInfo = false}) async { @@ -195,80 +195,83 @@ class _ChatHomeScreenState extends BaseStateFulWidgetState with return BlocBuilder( builder: (context, state) { - // wallet loaded no - if (!(state is WalletLoaded)) { - return Container( - child: SpinKitThreeBounce( - color: application.theme.primaryColor, - size: Settings.screenWidth() / 15, - ), - ); - } - // wallet loaded yes - if (state.isWalletsEmpty()) { - return ChatNoWalletLayout(); - } else if (!dbOpen && (dbUpdateTip?.isNotEmpty == true)) { - return _dbUpgradeTip(); - } else if (!connected || (state.defaultWallet() == null)) { - return ChatNoConnectLayout((w) async { - bool succeed = await _tryLogin(wallet: w); - if (succeed) { - _refreshContactMe(deviceInfo: true); - } - }); - } - // client connected - return Layout( - headerColor: application.theme.primaryColor, - bodyColor: application.theme.backgroundLightColor, - header: Header( - titleChild: Container( - margin: EdgeInsets.only(left: 20), - child: _contactMe != null - ? ContactHeader( - contact: _contactMe!, - onTap: () { - ContactProfileScreen.go(context, address: _contactMe?.address); - }, - body: _headerBody(), - ) - : SizedBox.shrink(), + return Consumer(builder: (context, ref, _) { + // wallet loaded no + if (!(state is WalletLoaded)) { + return Container( + child: SpinKitThreeBounce( + color: application.theme.primaryColor, + size: Settings.screenWidth() / 15, + ), + ); + } + // wallet loaded yes + final connectedByProvider = ref.watch(connectedProvider); + if (state.isWalletsEmpty()) { + return ChatNoWalletLayout(); + } else if (!dbOpen && (dbUpdateTip?.isNotEmpty == true)) { + return _dbUpgradeTip(); + } else if (!connectedByProvider || (state.defaultWallet() == null)) { + return ChatNoConnectLayout((w) async { + bool succeed = await _tryLogin(wallet: w); + if (succeed) { + _refreshContactMe(deviceInfo: true); + } + }); + } + // client connected + return Layout( + headerColor: application.theme.primaryColor, + bodyColor: application.theme.backgroundLightColor, + header: Header( + titleChild: Container( + margin: EdgeInsets.only(left: 20), + child: _contactMe != null + ? ContactHeader( + contact: _contactMe!, + onTap: () { + ContactProfileScreen.go(context, address: _contactMe?.address); + }, + body: _headerBody(), + ) + : SizedBox.shrink(), + ), + actions: [ + Padding( + padding: const EdgeInsets.only(right: 8), + child: IconButton( + icon: Asset.iconSvg('addbook', color: Colors.white, width: 24), + onPressed: () { + ContactHomeScreen.go(context); + }, + ), + ) + ], ), - actions: [ - Padding( - padding: const EdgeInsets.only(right: 8), - child: IconButton( - icon: Asset.iconSvg('addbook', color: Colors.white, width: 24), - onPressed: () { - ContactHomeScreen.go(context); - }, - ), - ) - ], - ), - floatingActionButtonLocation: FloatingActionButtonLocation.endFloat, - floatingActionButton: Padding( - padding: EdgeInsets.only(bottom: 60, right: 4), - child: FloatingActionButton( - key: _floatingActionKey, - shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(32)), - elevation: 12, - backgroundColor: application.theme.primaryColor, - child: Asset.iconSvg('pencil', width: 24), - onPressed: () { - _showFloatActionMenu(); - }, + floatingActionButtonLocation: FloatingActionButtonLocation.endFloat, + floatingActionButton: Padding( + padding: EdgeInsets.only(bottom: 60, right: 4), + child: FloatingActionButton( + key: _floatingActionKey, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(32)), + elevation: 12, + backgroundColor: application.theme.primaryColor, + child: Asset.iconSvg('pencil', width: 24), + onPressed: () { + _showFloatActionMenu(); + }, + ), ), - ), - body: (_contactMe != null) && dbOpen - ? ChatSessionListLayout(_contactMe!) - : Container( - child: SpinKitThreeBounce( - color: application.theme.primaryColor, - size: Settings.screenWidth() / 15, + body: (_contactMe != null) && dbOpen + ? ChatSessionListLayout(_contactMe!) + : Container( + child: SpinKitThreeBounce( + color: application.theme.primaryColor, + size: Settings.screenWidth() / 15, + ), ), - ), - ); + ); + }); }, ); } diff --git a/lib/screens/contact/profile.dart b/lib/screens/contact/profile.dart index f8aa2d85e..83aefc8de 100644 --- a/lib/screens/contact/profile.dart +++ b/lib/screens/contact/profile.dart @@ -3,6 +3,7 @@ import 'dart:io'; import 'package:flutter/cupertino.dart'; import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_slidable/flutter_slidable.dart'; import 'package:font_awesome_flutter/font_awesome_flutter.dart'; import 'package:image_cropper/image_cropper.dart'; @@ -33,6 +34,8 @@ import 'package:nmobile/utils/logger.dart'; import 'package:nmobile/utils/path.dart'; import 'package:nmobile/utils/util.dart'; +import '../../providers/connected_provider.dart'; + class ContactProfileScreen extends BaseStateFulWidget { static const String routeName = '/contact/profile'; static final String argContactSchema = "contact_schema"; @@ -258,6 +261,11 @@ class _ContactProfileScreenState extends BaseStateFulWidgetState s.delete, ctx: context), color: application.theme.fontLightColor, @@ -731,7 +742,11 @@ class _ContactProfileScreenState extends BaseStateFulWidgetState s.delete, ctx: context), color: application.theme.fontLightColor, diff --git a/pubspec.lock b/pubspec.lock index 395f098e5..23eb7cd42 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -53,10 +53,10 @@ packages: dependency: transitive description: name: async - sha256: "947bfcf187f74dbc5e146c9eb9c0f10c9f8b30743e341481c1e2ed3ecc18c20c" + sha256: "758e6d74e971c3e5aceb4110bfd6698efc7f501675bcfe0c775459a8140750eb" url: "https://pub.flutter-io.cn" source: hosted - version: "2.11.0" + version: "2.13.0" audio_session: dependency: "direct main" description: @@ -89,6 +89,14 @@ packages: url: "https://pub.flutter-io.cn" source: hosted version: "7.2.1" + boolean_selector: + dependency: transitive + description: + name: boolean_selector + sha256: "8aab1771e1243a5063b8b0ff68042d67334e3feab9e95b9490f9a6ebf73b42ea" + url: "https://pub.flutter-io.cn" + source: hosted + version: "2.1.2" bot_toast: dependency: "direct main" description: @@ -105,6 +113,22 @@ packages: url: "https://pub.flutter-io.cn" source: hosted version: "1.4.0" + checked_yaml: + dependency: transitive + description: + name: checked_yaml + sha256: "959525d3162f249993882720d52b7e0c833978df229be20702b33d48d91de70f" + url: "https://pub.flutter-io.cn" + source: hosted + version: "2.0.4" + cli_config: + dependency: transitive + description: + name: cli_config + sha256: ac20a183a07002b700f0c25e61b7ee46b23c309d76ab7b7640a028f18e4d99ec + url: "https://pub.flutter-io.cn" + source: hosted + version: "0.2.0" clock: dependency: transitive description: @@ -145,6 +169,14 @@ packages: url: "https://pub.flutter-io.cn" source: hosted version: "3.1.1" + coverage: + dependency: transitive + description: + name: coverage + sha256: "4b8701e48a58f7712492c9b1f7ba0bb9d525644dd66d023b62e1fc8cdb560c8a" + url: "https://pub.flutter-io.cn" + source: hosted + version: "1.14.0" cross_file: dependency: transitive description: @@ -257,6 +289,14 @@ packages: url: "https://pub.flutter-io.cn" source: hosted version: "2.0.5" + fake_async: + dependency: transitive + description: + name: fake_async + sha256: "5368f224a74523e8d2e7399ea1638b37aecfca824a3cc4dfdf77bf1fa905ac44" + url: "https://pub.flutter-io.cn" + source: hosted + version: "1.3.3" ffi: dependency: transitive description: @@ -451,6 +491,14 @@ packages: url: "https://pub.flutter-io.cn" source: hosted version: "2.0.23" + flutter_riverpod: + dependency: "direct main" + description: + name: flutter_riverpod + sha256: ca2480512a8e840291325249f4857e363ffa5d1b77b132e189c9313a9d9fb9e0 + url: "https://pub.flutter-io.cn" + source: hosted + version: "3.0.0" flutter_screenutil: dependency: "direct main" description: @@ -563,6 +611,11 @@ packages: url: "https://pub.flutter-io.cn" source: hosted version: "1.1.6" + flutter_test: + dependency: transitive + description: flutter + source: sdk + version: "0.0.0" flutter_web_plugins: dependency: transitive description: flutter @@ -576,6 +629,14 @@ packages: url: "https://pub.flutter-io.cn" source: hosted version: "10.5.0" + frontend_server_client: + dependency: transitive + description: + name: frontend_server_client + sha256: f64a0333a82f30b0cca061bc3d143813a486dc086b574bfb233b7c1372427694 + url: "https://pub.flutter-io.cn" + source: hosted + version: "4.0.0" get_it: dependency: "direct main" description: @@ -616,6 +677,14 @@ packages: url: "https://pub.flutter-io.cn" source: hosted version: "1.2.2" + http_multi_server: + dependency: transitive + description: + name: http_multi_server + sha256: aa6199f908078bb1c5efb8d8638d4ae191aac11b311132c3ef48ce352fb52ef8 + url: "https://pub.flutter-io.cn" + source: hosted + version: "3.2.2" http_parser: dependency: transitive description: @@ -743,6 +812,14 @@ packages: url: "https://pub.flutter-io.cn" source: hosted version: "2.8.11" + io: + dependency: transitive + description: + name: io + sha256: dfd5a80599cf0165756e3181807ed3e77daf6dd4137caaad72d0b7931597650b + url: "https://pub.flutter-io.cn" + source: hosted + version: "1.0.5" js: dependency: transitive description: @@ -751,6 +828,14 @@ packages: url: "https://pub.flutter-io.cn" source: hosted version: "0.6.7" + json_annotation: + dependency: transitive + description: + name: json_annotation + sha256: "1ce844379ca14835a50d2f019a3099f419082cfdd231cd86a142af94dd5c6bb1" + url: "https://pub.flutter-io.cn" + source: hosted + version: "4.9.0" json_rpc_2: dependency: transitive description: @@ -759,6 +844,30 @@ packages: url: "https://pub.flutter-io.cn" source: hosted version: "3.0.2" + leak_tracker: + dependency: transitive + description: + name: leak_tracker + sha256: "33e2e26bdd85a0112ec15400c8cbffea70d0f9c3407491f672a2fad47915e2de" + url: "https://pub.flutter-io.cn" + source: hosted + version: "11.0.2" + leak_tracker_flutter_testing: + dependency: transitive + description: + name: leak_tracker_flutter_testing + sha256: "1dbc140bb5a23c75ea9c4811222756104fbcd1a27173f0c34ca01e16bea473c1" + url: "https://pub.flutter-io.cn" + source: hosted + version: "3.0.10" + leak_tracker_testing: + dependency: transitive + description: + name: leak_tracker_testing + sha256: "8d5a2d49f4a66b49744b23b018848400d23e54caf9463f4eb20df3eb8acb2eb1" + url: "https://pub.flutter-io.cn" + source: hosted + version: "3.0.2" local_auth: dependency: "direct main" description: @@ -807,6 +916,14 @@ packages: url: "https://pub.flutter-io.cn" source: hosted version: "2.5.0" + logging: + dependency: transitive + description: + name: logging + sha256: c8245ada5f1717ed44271ed1c26b8ce85ca3228fd2ffdb75468ab01979309d61 + url: "https://pub.flutter-io.cn" + source: hosted + version: "1.3.0" markdown: dependency: transitive description: @@ -815,6 +932,14 @@ packages: url: "https://pub.flutter-io.cn" source: hosted version: "7.1.1" + matcher: + dependency: transitive + description: + name: matcher + sha256: dc58c723c3c24bf8d3e2d3ad3f2f9d7bd9cf43ec6feaa64181775e60190153f2 + url: "https://pub.flutter-io.cn" + source: hosted + version: "0.12.17" material_color_utilities: dependency: transitive description: @@ -878,6 +1003,14 @@ packages: url: "https://pub.flutter-io.cn" source: hosted version: "0.5.0" + node_preamble: + dependency: transitive + description: + name: node_preamble + sha256: "6e7eac89047ab8a8d26cf16127b5ed26de65209847630400f9aefd7cd5c730db" + url: "https://pub.flutter-io.cn" + source: hosted + version: "2.0.2" octo_image: dependency: "direct main" description: @@ -1078,6 +1211,14 @@ packages: url: "https://pub.flutter-io.cn" source: hosted version: "3.7.3" + pool: + dependency: transitive + description: + name: pool + sha256: "20fe868b6314b322ea036ba325e6fc0711a22948856475e2c2b6306e8ab39c2a" + url: "https://pub.flutter-io.cn" + source: hosted + version: "1.5.1" process: dependency: transitive description: @@ -1102,6 +1243,14 @@ packages: url: "https://pub.flutter-io.cn" source: hosted version: "2.1.4" + pubspec_parse: + dependency: transitive + description: + name: pubspec_parse + sha256: "0560ba233314abbed0a48a2956f7f022cce7c3e1e73df540277da7544cad4082" + url: "https://pub.flutter-io.cn" + source: hosted + version: "1.5.0" pull_to_refresh: dependency: "direct main" description: @@ -1142,6 +1291,14 @@ packages: url: "https://pub.flutter-io.cn" source: hosted version: "1.8.1" + riverpod: + dependency: transitive + description: + name: riverpod + sha256: "135723ec44dfba141bc4696224048a408336e794228a0117439e7ad0a8be6d05" + url: "https://pub.flutter-io.cn" + source: hosted + version: "3.0.0" rxdart: dependency: transitive description: @@ -1246,11 +1403,59 @@ packages: url: "https://pub.flutter-io.cn" source: hosted version: "2.4.1" + shelf: + dependency: transitive + description: + name: shelf + sha256: ad29c505aee705f41a4d8963641f91ac4cee3c8fad5947e033390a7bd8180fa4 + url: "https://pub.flutter-io.cn" + source: hosted + version: "1.4.1" + shelf_packages_handler: + dependency: transitive + description: + name: shelf_packages_handler + sha256: "89f967eca29607c933ba9571d838be31d67f53f6e4ee15147d5dc2934fee1b1e" + url: "https://pub.flutter-io.cn" + source: hosted + version: "3.0.2" + shelf_static: + dependency: transitive + description: + name: shelf_static + sha256: c87c3875f91262785dade62d135760c2c69cb217ac759485334c5857ad89f6e3 + url: "https://pub.flutter-io.cn" + source: hosted + version: "1.1.3" + shelf_web_socket: + dependency: transitive + description: + name: shelf_web_socket + sha256: "3632775c8e90d6c9712f883e633716432a27758216dfb61bd86a8321c0580925" + url: "https://pub.flutter-io.cn" + source: hosted + version: "3.0.0" sky_engine: dependency: transitive description: flutter source: sdk version: "0.0.0" + source_map_stack_trace: + dependency: transitive + description: + name: source_map_stack_trace + sha256: c0713a43e323c3302c2abe2a1cc89aa057a387101ebd280371d6a6c9fa68516b + url: "https://pub.flutter-io.cn" + source: hosted + version: "2.1.2" + source_maps: + dependency: transitive + description: + name: source_maps + sha256: "190222579a448b03896e0ca6eca5998fa810fda630c1d65e2f78b3f638f54812" + url: "https://pub.flutter-io.cn" + source: hosted + version: "0.10.13" source_span: dependency: transitive description: @@ -1286,18 +1491,26 @@ packages: dependency: transitive description: name: stack_trace - sha256: "73713990125a6d93122541237550ee3352a2d84baad52d375a4cad2eb9b7ce0b" + sha256: "8b27215b45d22309b5cddda1aa2b19bdfec9df0e765f2de506401c071d38d1b1" url: "https://pub.flutter-io.cn" source: hosted - version: "1.11.1" + version: "1.12.1" + state_notifier: + dependency: transitive + description: + name: state_notifier + sha256: b8677376aa54f2d7c58280d5a007f9e8774f1968d1fb1c096adcb4792fba29bb + url: "https://pub.flutter-io.cn" + source: hosted + version: "1.0.0" stream_channel: dependency: transitive description: name: stream_channel - sha256: ba2aa5d8cc609d96bbb2899c28934f9e1af5cddbd60a827822ea467161eb54e7 + sha256: "969e04c80b8bcdf826f8f16579c7b14d780458bd97f56d107d3950fdbeef059d" url: "https://pub.flutter-io.cn" source: hosted - version: "2.1.2" + version: "2.1.4" stream_transform: dependency: transitive description: @@ -1330,6 +1543,30 @@ packages: url: "https://pub.flutter-io.cn" source: hosted version: "1.2.1" + test: + dependency: transitive + description: + name: test + sha256: "65e29d831719be0591f7b3b1a32a3cda258ec98c58c7b25f7b84241bc31215bb" + url: "https://pub.flutter-io.cn" + source: hosted + version: "1.26.2" + test_api: + dependency: transitive + description: + name: test_api + sha256: "522f00f556e73044315fa4585ec3270f1808a4b186c936e612cab0b565ff1e00" + url: "https://pub.flutter-io.cn" + source: hosted + version: "0.7.6" + test_core: + dependency: transitive + description: + name: test_core + sha256: "80bf5a02b60af04b09e14f6fe68b921aad119493e26e490deaca5993fef1b05a" + url: "https://pub.flutter-io.cn" + source: hosted + version: "0.6.11" timezone: dependency: transitive description: @@ -1498,6 +1735,14 @@ packages: url: "https://pub.flutter-io.cn" source: hosted version: "0.5.3" + vm_service: + dependency: transitive + description: + name: vm_service + sha256: "45caa6c5917fa127b5dbcfbd1fa60b14e583afdc08bfc96dda38886ca252eb60" + url: "https://pub.flutter-io.cn" + source: hosted + version: "15.0.2" wallet: dependency: transitive description: @@ -1530,6 +1775,30 @@ packages: url: "https://pub.flutter-io.cn" source: hosted version: "2.7.1" + web_socket: + dependency: transitive + description: + name: web_socket + sha256: "34d64019aa8e36bf9842ac014bb5d2f5586ca73df5e4d9bf5c936975cae6982c" + url: "https://pub.flutter-io.cn" + source: hosted + version: "1.0.1" + web_socket_channel: + dependency: transitive + description: + name: web_socket_channel + sha256: d645757fb0f4773d602444000a8131ff5d48c9e47adfe9772652dd1a4f2d45c8 + url: "https://pub.flutter-io.cn" + source: hosted + version: "3.0.3" + webkit_inspection_protocol: + dependency: transitive + description: + name: webkit_inspection_protocol + sha256: "87d3f2333bb240704cd3f1c6b5b7acd8a10e7f0bc28c28dcf14e782014f4a572" + url: "https://pub.flutter-io.cn" + source: hosted + version: "1.2.1" webview_flutter: dependency: "direct main" description: @@ -1603,5 +1872,5 @@ packages: source: hosted version: "3.1.2" sdks: - dart: ">=3.8.0-0 <4.0.0" + dart: ">=3.8.0 <4.0.0" flutter: ">=3.27.0" diff --git a/pubspec.yaml b/pubspec.yaml index 18ec67166..794959de7 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -16,7 +16,7 @@ publish_to: 'none' # Remove this line if you wish to publish to pub.dev # Read more about iOS versioning at # https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html -version: 1.7.0+334 +version: 1.7.0+335 environment: sdk: ">=2.12.0 <3.0.0" @@ -45,6 +45,7 @@ dependencies: convert: ^3.1.1 flutter_bloc: ^7.2.0 equatable: ^2.0.5 + flutter_riverpod: ^3.0.0 synchronized: ^3.0.1 dio: ^4.0.6 get_it: ^7.2.0 From 1c25c9a30d0cfb38e26423273c420ce5ebb8f8b2 Mon Sep 17 00:00:00 2001 From: Heron Date: Wed, 17 Sep 2025 15:44:31 +0800 Subject: [PATCH 15/47] update emoji styles --- lib/components/chat/send_bar.dart | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/lib/components/chat/send_bar.dart b/lib/components/chat/send_bar.dart index 21d3ae9fe..15aefb8e1 100644 --- a/lib/components/chat/send_bar.dart +++ b/lib/components/chat/send_bar.dart @@ -389,18 +389,18 @@ class _ChatSendBarState extends BaseStateFulWidgetState { hintText: Settings.locale((s) => s.type_a_message, ctx: context), hintStyle: TextStyle(color: _theme.fontColor2), contentPadding: EdgeInsets.symmetric(vertical: 8, horizontal: 12), - prefixIcon: GestureDetector( + suffixIcon: GestureDetector( onTap: widget.onEmojiPressed, behavior: HitTestBehavior.opaque, child: Padding( - padding: EdgeInsets.only(left: 8, top: 4), + padding: EdgeInsets.only(right: 8, top: 4), child: FaIcon( FontAwesomeIcons.faceSmile, color: application.theme.primaryColor, ), ), ), - prefixIconConstraints: BoxConstraints(minHeight: 32, minWidth: 32), + suffixIconConstraints: BoxConstraints(minHeight: 32, minWidth: 32), border: UnderlineInputBorder( borderRadius: BorderRadius.all(Radius.circular(20)), borderSide: const BorderSide(width: 0, style: BorderStyle.none), From 17aae04337334aeebf0fa06083b61a2f8cf9a211 Mon Sep 17 00:00:00 2001 From: Heron Date: Mon, 22 Sep 2025 14:32:19 +0800 Subject: [PATCH 16/47] Upgrade project --- android/build.gradle.kts | 2 +- ios/Podfile.lock | 8 +- ios/Runner.xcodeproj/project.pbxproj | 235 +++++++------ .../RSIShareViewController.swift | 332 ++++++++++++++++++ ios/Share Extension/ShareViewController.swift | 26 +- .../SwiftReceiveSharingIntentPlugin.swift | 74 ++++ pubspec.yaml | 2 +- 7 files changed, 532 insertions(+), 147 deletions(-) create mode 100644 ios/Share Extension/RSIShareViewController.swift create mode 100644 ios/Share Extension/SwiftReceiveSharingIntentPlugin.swift diff --git a/android/build.gradle.kts b/android/build.gradle.kts index aeb91d75b..41bcc6586 100644 --- a/android/build.gradle.kts +++ b/android/build.gradle.kts @@ -39,7 +39,7 @@ subprojects { } val javaVersion = JavaVersion.VERSION_21 - val androidApiVersion = 35 + val androidApiVersion = 36 android.compileSdkVersion(androidApiVersion) android.defaultConfig.targetSdk = androidApiVersion diff --git a/ios/Podfile.lock b/ios/Podfile.lock index 23f167dce..98bed7b50 100644 --- a/ios/Podfile.lock +++ b/ios/Podfile.lock @@ -196,9 +196,9 @@ PODS: - MTBBarcodeScanner - receive_sharing_intent (1.8.1): - Flutter - - SDWebImage (5.21.0): - - SDWebImage/Core (= 5.21.0) - - SDWebImage/Core (5.21.0) + - SDWebImage (5.21.2): + - SDWebImage/Core (= 5.21.2) + - SDWebImage/Core (5.21.2) - SDWebImageWebPCoder (0.14.6): - libwebp (~> 1.0) - SDWebImage/Core (~> 5.17) @@ -408,7 +408,7 @@ SPEC CHECKSUMS: PromisesObjC: f5707f49cb48b9636751c5b2e7d227e43fba9f47 qr_code_scanner: d77f94ecc9abf96d9b9b8fc04ef13f611e5a147a receive_sharing_intent: 222384f00ffe7e952bbfabaa9e3967cb87e5fe00 - SDWebImage: f84b0feeb08d2d11e6a9b843cb06d75ebf5b8868 + SDWebImage: 9f177d83116802728e122410fb25ad88f5c7608a SDWebImageWebPCoder: e38c0a70396191361d60c092933e22c20d5b1380 Sentry: da60d980b197a46db0b35ea12cb8f39af48d8854 sentry_flutter: 27892878729f42701297c628eb90e7c6529f3684 diff --git a/ios/Runner.xcodeproj/project.pbxproj b/ios/Runner.xcodeproj/project.pbxproj index 355a5b949..6bd065202 100644 --- a/ios/Runner.xcodeproj/project.pbxproj +++ b/ios/Runner.xcodeproj/project.pbxproj @@ -3,19 +3,18 @@ archiveVersion = 1; classes = { }; - objectVersion = 54; + objectVersion = 77; objects = { /* Begin PBXBuildFile section */ + 0CEE64841036599BB324F1CC /* Pods_Share_Extension.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 9C257DECA1497057748992AF /* Pods_Share_Extension.framework */; }; + 142209D62E7AB38000C679DC /* Share Extension.appex in Embed App Extensions */ = {isa = PBXBuildFile; fileRef = 142209CC2E7AB38000C679DC /* Share Extension.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; }; 148FFB1A2E60499C005410D0 /* GoogleService-Info.plist in Resources */ = {isa = PBXBuildFile; fileRef = 148FFB192E60499C005410D0 /* GoogleService-Info.plist */; }; - 148FFB232E605654005410D0 /* GoogleService-Info.plist in Resources */ = {isa = PBXBuildFile; fileRef = 148FFB192E60499C005410D0 /* GoogleService-Info.plist */; }; 1498D2341E8E89220040F4C2 /* GeneratedPluginRegistrant.m in Sources */ = {isa = PBXBuildFile; fileRef = 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */; }; 149BC80728FEAEB000D27A6D /* DnsResolver.swift in Sources */ = {isa = PBXBuildFile; fileRef = 149BC80628FEAEB000D27A6D /* DnsResolver.swift */; }; 14A871CE287D6DB00093692A /* EthResolver.swift in Sources */ = {isa = PBXBuildFile; fileRef = 14A871CD287D6DAF0093692A /* EthResolver.swift */; }; - 14CD34D62D4F43B300789E6D /* Share Extension.appex in Embed App Extensions */ = {isa = PBXBuildFile; fileRef = 14CD34CC2D4F43B300789E6D /* Share Extension.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; }; - 26F296F8EDE0173FA7F16398 /* Pods_Share_Extension.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 91E30255B5E624B5EF52D2D5 /* Pods_Share_Extension.framework */; }; + 2965BED301999E2B8AAC496C /* Pods_Runner.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 334D81B7E27D6131B561C943 /* Pods_Runner.framework */; }; 3B3967161E833CAA004F5970 /* AppFrameworkInfo.plist in Resources */ = {isa = PBXBuildFile; fileRef = 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */; }; - 405621D90C76768D15AE18AC /* Pods_Runner.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 0AEEB1EA9E73BDE1EEC27FDA /* Pods_Runner.framework */; }; 74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 74858FAE1ED2DC5600515810 /* AppDelegate.swift */; }; 97C146FC1CF9000F007C117D /* Main.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FA1CF9000F007C117D /* Main.storyboard */; }; 97C146FE1CF9000F007C117D /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FD1CF9000F007C117D /* Assets.xcassets */; }; @@ -45,11 +44,11 @@ /* End PBXBuildFile section */ /* Begin PBXContainerItemProxy section */ - 14CD34D42D4F43B300789E6D /* PBXContainerItemProxy */ = { + 142209D42E7AB38000C679DC /* PBXContainerItemProxy */ = { isa = PBXContainerItemProxy; containerPortal = 97C146E61CF9000F007C117D /* Project object */; proxyType = 1; - remoteGlobalIDString = 14CD34CB2D4F43B300789E6D; + remoteGlobalIDString = 142209CB2E7AB38000C679DC; remoteInfo = "Share Extension"; }; /* End PBXContainerItemProxy section */ @@ -67,11 +66,11 @@ }; E1B3F2B6291BA8040042F7B2 /* Embed App Extensions */ = { isa = PBXCopyFilesBuildPhase; - buildActionMask = 2147483647; + buildActionMask = 12; dstPath = ""; dstSubfolderSpec = 13; files = ( - 14CD34D62D4F43B300789E6D /* Share Extension.appex in Embed App Extensions */, + 142209D62E7AB38000C679DC /* Share Extension.appex in Embed App Extensions */, ); name = "Embed App Extensions"; runOnlyForDeploymentPostprocessing = 0; @@ -79,24 +78,22 @@ /* End PBXCopyFilesBuildPhase section */ /* Begin PBXFileReference section */ - 00DDD0F3B83E3CC2A5212805 /* Pods-Share Extension.profile.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Share Extension.profile.xcconfig"; path = "Target Support Files/Pods-Share Extension/Pods-Share Extension.profile.xcconfig"; sourceTree = ""; }; - 0AEEB1EA9E73BDE1EEC27FDA /* Pods_Runner.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_Runner.framework; sourceTree = BUILT_PRODUCTS_DIR; }; + 02EED84DF132B94A356E3DA4 /* Pods-Share Extension.profile.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Share Extension.profile.xcconfig"; path = "Target Support Files/Pods-Share Extension/Pods-Share Extension.profile.xcconfig"; sourceTree = ""; }; + 0C98E54980A890E1BD057FA6 /* Pods-Runner.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.debug.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.debug.xcconfig"; sourceTree = ""; }; + 142209AC2E7AB14500C679DC /* receive_sharing_intent.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; path = receive_sharing_intent.framework; sourceTree = BUILT_PRODUCTS_DIR; }; + 142209CC2E7AB38000C679DC /* Share Extension.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = "Share Extension.appex"; sourceTree = BUILT_PRODUCTS_DIR; }; 148FFB192E60499C005410D0 /* GoogleService-Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = "GoogleService-Info.plist"; sourceTree = ""; }; 1498D2321E8E86230040F4C2 /* GeneratedPluginRegistrant.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = GeneratedPluginRegistrant.h; sourceTree = ""; }; 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = GeneratedPluginRegistrant.m; sourceTree = ""; }; 149BC80628FEAEB000D27A6D /* DnsResolver.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DnsResolver.swift; sourceTree = ""; }; 14A871CD287D6DAF0093692A /* EthResolver.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EthResolver.swift; sourceTree = ""; }; - 14CD34CC2D4F43B300789E6D /* Share Extension.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = "Share Extension.appex"; sourceTree = BUILT_PRODUCTS_DIR; }; - 2B0A2868D4D098A65BBC45F1 /* Pods_Sharing_Extension.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_Sharing_Extension.framework; sourceTree = BUILT_PRODUCTS_DIR; }; - 2F1390E9C951F8699CC4CC1B /* Pods-Share Extension.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Share Extension.release.xcconfig"; path = "Target Support Files/Pods-Share Extension/Pods-Share Extension.release.xcconfig"; sourceTree = ""; }; - 351D750FD58E06401D12F03A /* Pods-Sharing Extension.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Sharing Extension.release.xcconfig"; path = "Target Support Files/Pods-Sharing Extension/Pods-Sharing Extension.release.xcconfig"; sourceTree = ""; }; + 2BD846195C7A4EF53B2AAABB /* Pods-Runner.profile.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.profile.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.profile.xcconfig"; sourceTree = ""; }; + 334D81B7E27D6131B561C943 /* Pods_Runner.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_Runner.framework; sourceTree = BUILT_PRODUCTS_DIR; }; 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; name = AppFrameworkInfo.plist; path = Flutter/AppFrameworkInfo.plist; sourceTree = ""; }; - 3D26C5798BF716A04D05B747 /* Pods-Sharing Extension.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Sharing Extension.debug.xcconfig"; path = "Target Support Files/Pods-Sharing Extension/Pods-Sharing Extension.debug.xcconfig"; sourceTree = ""; }; - 5C3F370BC1B3BB2B5BE7210C /* Pods-Runner.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.debug.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.debug.xcconfig"; sourceTree = ""; }; 74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "Runner-Bridging-Header.h"; sourceTree = ""; }; 74858FAE1ED2DC5600515810 /* AppDelegate.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = ""; }; 7AFA3C8E1D35360C0083082E /* Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; name = Release.xcconfig; path = Flutter/Release.xcconfig; sourceTree = ""; }; - 91E30255B5E624B5EF52D2D5 /* Pods_Share_Extension.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_Share_Extension.framework; sourceTree = BUILT_PRODUCTS_DIR; }; + 8AF95113A5629FC499DAE55F /* Pods-Runner.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.release.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.release.xcconfig"; sourceTree = ""; }; 9740EEB21CF90195004384FC /* Debug.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; name = Debug.xcconfig; path = Flutter/Debug.xcconfig; sourceTree = ""; }; 9740EEB31CF90195004384FC /* Generated.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; name = Generated.xcconfig; path = Flutter/Generated.xcconfig; sourceTree = ""; }; 97C146EE1CF9000F007C117D /* Runner.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Runner.app; sourceTree = BUILT_PRODUCTS_DIR; }; @@ -104,9 +101,8 @@ 97C146FD1CF9000F007C117D /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; 97C147001CF9000F007C117D /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/LaunchScreen.storyboard; sourceTree = ""; }; 97C147021CF9000F007C117D /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; - A5F39BA325F55CCD6883EEB7 /* Pods-Runner.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.release.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.release.xcconfig"; sourceTree = ""; }; - C210A1A73EDB772675FF4A58 /* Pods-Runner.profile.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.profile.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.profile.xcconfig"; sourceTree = ""; }; - C5AB1F67FA58708668EABC72 /* Pods-Sharing Extension.profile.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Sharing Extension.profile.xcconfig"; path = "Target Support Files/Pods-Sharing Extension/Pods-Sharing Extension.profile.xcconfig"; sourceTree = ""; }; + 9C257DECA1497057748992AF /* Pods_Share_Extension.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_Share_Extension.framework; sourceTree = BUILT_PRODUCTS_DIR; }; + BD68F6F0F500A9479AA449AA /* Pods-Share Extension.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Share Extension.release.xcconfig"; path = "Target Support Files/Pods-Share Extension/Pods-Share Extension.release.xcconfig"; sourceTree = ""; }; C61A6E6F266A1A7C00A7F1E1 /* Common.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Common.swift; sourceTree = ""; }; C62BB8042689AA0D00DAAC43 /* NWSSLConnection.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = NWSSLConnection.h; sourceTree = ""; }; C62BB8052689AA0D00DAAC43 /* NWSecTools.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = NWSecTools.h; sourceTree = ""; }; @@ -140,24 +136,24 @@ C6FFD762266E09E600410547 /* CommonOc.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = CommonOc.h; sourceTree = ""; }; C6FFD763266E09E600410547 /* CommonOc.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = CommonOc.m; sourceTree = ""; }; E1852ED72878179100FC45FD /* Crypto.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Crypto.swift; sourceTree = ""; }; - FF85B414D2042FADD297DB3E /* Pods-Share Extension.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Share Extension.debug.xcconfig"; path = "Target Support Files/Pods-Share Extension/Pods-Share Extension.debug.xcconfig"; sourceTree = ""; }; + EA4800B8A7D823487FC27079 /* Pods-Share Extension.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Share Extension.debug.xcconfig"; path = "Target Support Files/Pods-Share Extension/Pods-Share Extension.debug.xcconfig"; sourceTree = ""; }; /* End PBXFileReference section */ /* Begin PBXFileSystemSynchronizedBuildFileExceptionSet section */ - 14CD34D72D4F43B300789E6D /* Exceptions for "Share Extension" folder in "Share Extension" target */ = { + 142209D72E7AB38000C679DC /* Exceptions for "Share Extension" folder in "Share Extension" target */ = { isa = PBXFileSystemSynchronizedBuildFileExceptionSet; membershipExceptions = ( Info.plist, ); - target = 14CD34CB2D4F43B300789E6D /* Share Extension */; + target = 142209CB2E7AB38000C679DC /* Share Extension */; }; /* End PBXFileSystemSynchronizedBuildFileExceptionSet section */ /* Begin PBXFileSystemSynchronizedRootGroup section */ - 14CD34CD2D4F43B300789E6D /* Share Extension */ = { + 142209CD2E7AB38000C679DC /* Share Extension */ = { isa = PBXFileSystemSynchronizedRootGroup; exceptions = ( - 14CD34D72D4F43B300789E6D /* Exceptions for "Share Extension" folder in "Share Extension" target */, + 142209D72E7AB38000C679DC /* Exceptions for "Share Extension" folder in "Share Extension" target */, ); path = "Share Extension"; sourceTree = ""; @@ -165,11 +161,11 @@ /* End PBXFileSystemSynchronizedRootGroup section */ /* Begin PBXFrameworksBuildPhase section */ - 14CD34C92D4F43B300789E6D /* Frameworks */ = { + 142209C92E7AB38000C679DC /* Frameworks */ = { isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( - 26F296F8EDE0173FA7F16398 /* Pods_Share_Extension.framework in Frameworks */, + 0CEE64841036599BB324F1CC /* Pods_Share_Extension.framework in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -177,7 +173,7 @@ isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( - 405621D90C76768D15AE18AC /* Pods_Runner.framework in Frameworks */, + 2965BED301999E2B8AAC496C /* Pods_Runner.framework in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -204,25 +200,22 @@ 3D6E1C9BD273F4DA4DBFC297 /* Pods */ = { isa = PBXGroup; children = ( - 5C3F370BC1B3BB2B5BE7210C /* Pods-Runner.debug.xcconfig */, - A5F39BA325F55CCD6883EEB7 /* Pods-Runner.release.xcconfig */, - C210A1A73EDB772675FF4A58 /* Pods-Runner.profile.xcconfig */, - 3D26C5798BF716A04D05B747 /* Pods-Sharing Extension.debug.xcconfig */, - 351D750FD58E06401D12F03A /* Pods-Sharing Extension.release.xcconfig */, - C5AB1F67FA58708668EABC72 /* Pods-Sharing Extension.profile.xcconfig */, - FF85B414D2042FADD297DB3E /* Pods-Share Extension.debug.xcconfig */, - 2F1390E9C951F8699CC4CC1B /* Pods-Share Extension.release.xcconfig */, - 00DDD0F3B83E3CC2A5212805 /* Pods-Share Extension.profile.xcconfig */, + 0C98E54980A890E1BD057FA6 /* Pods-Runner.debug.xcconfig */, + 8AF95113A5629FC499DAE55F /* Pods-Runner.release.xcconfig */, + 2BD846195C7A4EF53B2AAABB /* Pods-Runner.profile.xcconfig */, + EA4800B8A7D823487FC27079 /* Pods-Share Extension.debug.xcconfig */, + BD68F6F0F500A9479AA449AA /* Pods-Share Extension.release.xcconfig */, + 02EED84DF132B94A356E3DA4 /* Pods-Share Extension.profile.xcconfig */, ); path = Pods; sourceTree = ""; }; - 6D8B405AB30FE86D2746B39D /* Frameworks */ = { + 594E66BF7E7D2CAB5FC62094 /* Frameworks */ = { isa = PBXGroup; children = ( - 0AEEB1EA9E73BDE1EEC27FDA /* Pods_Runner.framework */, - 2B0A2868D4D098A65BBC45F1 /* Pods_Sharing_Extension.framework */, - 91E30255B5E624B5EF52D2D5 /* Pods_Share_Extension.framework */, + 142209AC2E7AB14500C679DC /* receive_sharing_intent.framework */, + 334D81B7E27D6131B561C943 /* Pods_Runner.framework */, + 9C257DECA1497057748992AF /* Pods_Share_Extension.framework */, ); name = Frameworks; sourceTree = ""; @@ -244,10 +237,10 @@ C61A6E6D266A19F000A7F1E1 /* Classes */, 9740EEB11CF90186004384FC /* Flutter */, 97C146F01CF9000F007C117D /* Runner */, - 14CD34CD2D4F43B300789E6D /* Share Extension */, + 142209CD2E7AB38000C679DC /* Share Extension */, 97C146EF1CF9000F007C117D /* Products */, 3D6E1C9BD273F4DA4DBFC297 /* Pods */, - 6D8B405AB30FE86D2746B39D /* Frameworks */, + 594E66BF7E7D2CAB5FC62094 /* Frameworks */, ); sourceTree = ""; }; @@ -255,7 +248,7 @@ isa = PBXGroup; children = ( 97C146EE1CF9000F007C117D /* Runner.app */, - 14CD34CC2D4F43B300789E6D /* Share Extension.appex */, + 142209CC2E7AB38000C679DC /* Share Extension.appex */, ); name = Products; sourceTree = ""; @@ -352,46 +345,46 @@ /* End PBXGroup section */ /* Begin PBXNativeTarget section */ - 14CD34CB2D4F43B300789E6D /* Share Extension */ = { + 142209CB2E7AB38000C679DC /* Share Extension */ = { isa = PBXNativeTarget; - buildConfigurationList = 14CD34D82D4F43B300789E6D /* Build configuration list for PBXNativeTarget "Share Extension" */; + buildConfigurationList = 142209D82E7AB38000C679DC /* Build configuration list for PBXNativeTarget "Share Extension" */; buildPhases = ( - BCFE6FCF12ECC9CC408AED6A /* [CP] Check Pods Manifest.lock */, - 14CD34C82D4F43B300789E6D /* Sources */, - 14CD34C92D4F43B300789E6D /* Frameworks */, - 14CD34CA2D4F43B300789E6D /* Resources */, + C02F37618FCF833CBE8CCC80 /* [CP] Check Pods Manifest.lock */, + 142209C82E7AB38000C679DC /* Sources */, + 142209C92E7AB38000C679DC /* Frameworks */, + 142209CA2E7AB38000C679DC /* Resources */, ); buildRules = ( ); dependencies = ( ); fileSystemSynchronizedGroups = ( - 14CD34CD2D4F43B300789E6D /* Share Extension */, + 142209CD2E7AB38000C679DC /* Share Extension */, ); name = "Share Extension"; productName = "Share Extension"; - productReference = 14CD34CC2D4F43B300789E6D /* Share Extension.appex */; + productReference = 142209CC2E7AB38000C679DC /* Share Extension.appex */; productType = "com.apple.product-type.app-extension"; }; 97C146ED1CF9000F007C117D /* Runner */ = { isa = PBXNativeTarget; buildConfigurationList = 97C147051CF9000F007C117D /* Build configuration list for PBXNativeTarget "Runner" */; buildPhases = ( - 31910CF38E8EA38D5B7FA35B /* [CP] Check Pods Manifest.lock */, + 4D8F8F659CC99985592659DA /* [CP] Check Pods Manifest.lock */, 9705A1C41CF9048500538489 /* Embed Frameworks */, E1B3F2B6291BA8040042F7B2 /* Embed App Extensions */, - 12D1C02525059AFAAE4402B0 /* [CP] Embed Pods Frameworks */, 9740EEB61CF901F6004384FC /* Run Script */, 97C146EA1CF9000F007C117D /* Sources */, 97C146EB1CF9000F007C117D /* Frameworks */, 97C146EC1CF9000F007C117D /* Resources */, 3B06AD1E1E4923F5004D2608 /* Thin Binary */, - 60A233074E1A78C397C3A119 /* [CP] Copy Pods Resources */, + 9992624A3087AEB0CE096628 /* [CP] Embed Pods Frameworks */, + 7F5ECBC81637C095A2530E83 /* [CP] Copy Pods Resources */, ); buildRules = ( ); dependencies = ( - 14CD34D52D4F43B300789E6D /* PBXTargetDependency */, + 142209D52E7AB38000C679DC /* PBXTargetDependency */, ); name = Runner; productName = Runner; @@ -404,12 +397,12 @@ 97C146E61CF9000F007C117D /* Project object */ = { isa = PBXProject; attributes = { - LastSwiftUpdateCheck = 1620; + LastSwiftUpdateCheck = 2600; LastUpgradeCheck = 1510; ORGANIZATIONNAME = ""; TargetAttributes = { - 14CD34CB2D4F43B300789E6D = { - CreatedOnToolsVersion = 16.2; + 142209CB2E7AB38000C679DC = { + CreatedOnToolsVersion = 26.0; }; 97C146ED1CF9000F007C117D = { CreatedOnToolsVersion = 7.3.1; @@ -431,17 +424,16 @@ projectRoot = ""; targets = ( 97C146ED1CF9000F007C117D /* Runner */, - 14CD34CB2D4F43B300789E6D /* Share Extension */, + 142209CB2E7AB38000C679DC /* Share Extension */, ); }; /* End PBXProject section */ /* Begin PBXResourcesBuildPhase section */ - 14CD34CA2D4F43B300789E6D /* Resources */ = { + 142209CA2E7AB38000C679DC /* Resources */ = { isa = PBXResourcesBuildPhase; buildActionMask = 2147483647; files = ( - 148FFB232E605654005410D0 /* GoogleService-Info.plist in Resources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -463,24 +455,23 @@ /* End PBXResourcesBuildPhase section */ /* Begin PBXShellScriptBuildPhase section */ - 12D1C02525059AFAAE4402B0 /* [CP] Embed Pods Frameworks */ = { + 3B06AD1E1E4923F5004D2608 /* Thin Binary */ = { isa = PBXShellScriptBuildPhase; + alwaysOutOfDate = 1; buildActionMask = 2147483647; files = ( ); - inputFileListPaths = ( - "${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks-${CONFIGURATION}-input-files.xcfilelist", + inputPaths = ( + "${TARGET_BUILD_DIR}/${INFOPLIST_PATH}", ); - name = "[CP] Embed Pods Frameworks"; - outputFileListPaths = ( - "${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks-${CONFIGURATION}-output-files.xcfilelist", + name = "Thin Binary"; + outputPaths = ( ); runOnlyForDeploymentPostprocessing = 0; shellPath = /bin/sh; - shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks.sh\"\n"; - showEnvVarsInLog = 0; + shellScript = "/bin/sh \"$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh\" embed_and_thin\n"; }; - 31910CF38E8EA38D5B7FA35B /* [CP] Check Pods Manifest.lock */ = { + 4D8F8F659CC99985592659DA /* [CP] Check Pods Manifest.lock */ = { isa = PBXShellScriptBuildPhase; buildActionMask = 2147483647; files = ( @@ -502,23 +493,7 @@ shellScript = "diff \"${PODS_PODFILE_DIR_PATH}/Podfile.lock\" \"${PODS_ROOT}/Manifest.lock\" > /dev/null\nif [ $? != 0 ] ; then\n # print error to STDERR\n echo \"error: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation.\" >&2\n exit 1\nfi\n# This output is used by Xcode 'outputs' to avoid re-running this script phase.\necho \"SUCCESS\" > \"${SCRIPT_OUTPUT_FILE_0}\"\n"; showEnvVarsInLog = 0; }; - 3B06AD1E1E4923F5004D2608 /* Thin Binary */ = { - isa = PBXShellScriptBuildPhase; - alwaysOutOfDate = 1; - buildActionMask = 2147483647; - files = ( - ); - inputPaths = ( - "${TARGET_BUILD_DIR}/${INFOPLIST_PATH}", - ); - name = "Thin Binary"; - outputPaths = ( - ); - runOnlyForDeploymentPostprocessing = 0; - shellPath = /bin/sh; - shellScript = "/bin/sh \"$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh\" embed_and_thin"; - }; - 60A233074E1A78C397C3A119 /* [CP] Copy Pods Resources */ = { + 7F5ECBC81637C095A2530E83 /* [CP] Copy Pods Resources */ = { isa = PBXShellScriptBuildPhase; buildActionMask = 2147483647; files = ( @@ -526,10 +501,14 @@ inputFileListPaths = ( "${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-resources-${CONFIGURATION}-input-files.xcfilelist", ); + inputPaths = ( + ); name = "[CP] Copy Pods Resources"; outputFileListPaths = ( "${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-resources-${CONFIGURATION}-output-files.xcfilelist", ); + outputPaths = ( + ); runOnlyForDeploymentPostprocessing = 0; shellPath = /bin/sh; shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-resources.sh\"\n"; @@ -550,7 +529,28 @@ shellPath = /bin/sh; shellScript = "/bin/sh \"$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh\" build\n"; }; - BCFE6FCF12ECC9CC408AED6A /* [CP] Check Pods Manifest.lock */ = { + 9992624A3087AEB0CE096628 /* [CP] Embed Pods Frameworks */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + "${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks-${CONFIGURATION}-input-files.xcfilelist", + ); + inputPaths = ( + ); + name = "[CP] Embed Pods Frameworks"; + outputFileListPaths = ( + "${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks-${CONFIGURATION}-output-files.xcfilelist", + ); + outputPaths = ( + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks.sh\"\n"; + showEnvVarsInLog = 0; + }; + C02F37618FCF833CBE8CCC80 /* [CP] Check Pods Manifest.lock */ = { isa = PBXShellScriptBuildPhase; buildActionMask = 2147483647; files = ( @@ -575,7 +575,7 @@ /* End PBXShellScriptBuildPhase section */ /* Begin PBXSourcesBuildPhase section */ - 14CD34C82D4F43B300789E6D /* Sources */ = { + 142209C82E7AB38000C679DC /* Sources */ = { isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( @@ -615,10 +615,10 @@ /* End PBXSourcesBuildPhase section */ /* Begin PBXTargetDependency section */ - 14CD34D52D4F43B300789E6D /* PBXTargetDependency */ = { + 142209D52E7AB38000C679DC /* PBXTargetDependency */ = { isa = PBXTargetDependency; - target = 14CD34CB2D4F43B300789E6D /* Share Extension */; - targetProxy = 14CD34D42D4F43B300789E6D /* PBXContainerItemProxy */; + target = 142209CB2E7AB38000C679DC /* Share Extension */; + targetProxy = 142209D42E7AB38000C679DC /* PBXContainerItemProxy */; }; /* End PBXTargetDependency section */ @@ -642,9 +642,9 @@ /* End PBXVariantGroup section */ /* Begin XCBuildConfiguration section */ - 14CD34D92D4F43B300789E6D /* Debug */ = { + 142209D92E7AB38000C679DC /* Debug */ = { isa = XCBuildConfiguration; - baseConfigurationReference = FF85B414D2042FADD297DB3E /* Pods-Share Extension.debug.xcconfig */; + baseConfigurationReference = EA4800B8A7D823487FC27079 /* Pods-Share Extension.debug.xcconfig */; buildSettings = { ALWAYS_SEARCH_USER_PATHS = NO; ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; @@ -654,7 +654,6 @@ CLANG_WARN_DOCUMENTATION_COMMENTS = YES; CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; - CODE_SIGN_ENTITLEMENTS = "Share Extension/Share Extension.entitlements"; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; @@ -665,7 +664,7 @@ INFOPLIST_FILE = "Share Extension/Info.plist"; INFOPLIST_KEY_CFBundleDisplayName = "Share Extension"; INFOPLIST_KEY_NSHumanReadableCopyright = ""; - IPHONEOS_DEPLOYMENT_TARGET = 13.0; + IPHONEOS_DEPLOYMENT_TARGET = 26.0; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", @@ -678,17 +677,20 @@ PRODUCT_BUNDLE_IDENTIFIER = "org.nkn.nmobile.Share-Extension"; PRODUCT_NAME = "$(TARGET_NAME)"; SKIP_INSTALL = YES; + STRING_CATALOG_GENERATE_SYMBOLS = YES; SWIFT_ACTIVE_COMPILATION_CONDITIONS = "DEBUG $(inherited)"; + SWIFT_APPROACHABLE_CONCURRENCY = YES; SWIFT_EMIT_LOC_STRINGS = YES; SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES; SWIFT_VERSION = 5.0; TARGETED_DEVICE_FAMILY = "1,2"; }; name = Debug; }; - 14CD34DA2D4F43B300789E6D /* Release */ = { + 142209DA2E7AB38000C679DC /* Release */ = { isa = XCBuildConfiguration; - baseConfigurationReference = 2F1390E9C951F8699CC4CC1B /* Pods-Share Extension.release.xcconfig */; + baseConfigurationReference = BD68F6F0F500A9479AA449AA /* Pods-Share Extension.release.xcconfig */; buildSettings = { ALWAYS_SEARCH_USER_PATHS = NO; ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; @@ -698,7 +700,6 @@ CLANG_WARN_DOCUMENTATION_COMMENTS = YES; CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; - CODE_SIGN_ENTITLEMENTS = "Share Extension/Share Extension.entitlements"; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; @@ -709,7 +710,7 @@ INFOPLIST_FILE = "Share Extension/Info.plist"; INFOPLIST_KEY_CFBundleDisplayName = "Share Extension"; INFOPLIST_KEY_NSHumanReadableCopyright = ""; - IPHONEOS_DEPLOYMENT_TARGET = 13.0; + IPHONEOS_DEPLOYMENT_TARGET = 26.0; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", @@ -721,15 +722,18 @@ PRODUCT_BUNDLE_IDENTIFIER = "org.nkn.nmobile.Share-Extension"; PRODUCT_NAME = "$(TARGET_NAME)"; SKIP_INSTALL = YES; + STRING_CATALOG_GENERATE_SYMBOLS = YES; + SWIFT_APPROACHABLE_CONCURRENCY = YES; SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES; SWIFT_VERSION = 5.0; TARGETED_DEVICE_FAMILY = "1,2"; }; name = Release; }; - 14CD34DB2D4F43B300789E6D /* Profile */ = { + 142209DB2E7AB38000C679DC /* Profile */ = { isa = XCBuildConfiguration; - baseConfigurationReference = 00DDD0F3B83E3CC2A5212805 /* Pods-Share Extension.profile.xcconfig */; + baseConfigurationReference = 02EED84DF132B94A356E3DA4 /* Pods-Share Extension.profile.xcconfig */; buildSettings = { ALWAYS_SEARCH_USER_PATHS = NO; ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; @@ -739,7 +743,6 @@ CLANG_WARN_DOCUMENTATION_COMMENTS = YES; CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; - CODE_SIGN_ENTITLEMENTS = "Share Extension/Share Extension.entitlements"; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; @@ -750,7 +753,7 @@ INFOPLIST_FILE = "Share Extension/Info.plist"; INFOPLIST_KEY_CFBundleDisplayName = "Share Extension"; INFOPLIST_KEY_NSHumanReadableCopyright = ""; - IPHONEOS_DEPLOYMENT_TARGET = 13.0; + IPHONEOS_DEPLOYMENT_TARGET = 26.0; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", @@ -762,7 +765,10 @@ PRODUCT_BUNDLE_IDENTIFIER = "org.nkn.nmobile.Share-Extension"; PRODUCT_NAME = "$(TARGET_NAME)"; SKIP_INSTALL = YES; + STRING_CATALOG_GENERATE_SYMBOLS = YES; + SWIFT_APPROACHABLE_CONCURRENCY = YES; SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES; SWIFT_VERSION = 5.0; TARGETED_DEVICE_FAMILY = "1,2"; }; @@ -823,13 +829,12 @@ isa = XCBuildConfiguration; baseConfigurationReference = 7AFA3C8E1D35360C0083082E /* Release.xcconfig */; buildSettings = { - ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES; ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; CLANG_ENABLE_MODULES = YES; CODE_SIGN_ENTITLEMENTS = Runner/Runner.entitlements; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 335; + CURRENT_PROJECT_VERSION = 336; DEVELOPMENT_TEAM = 67P82ZQDAS; ENABLE_BITCODE = NO; INFOPLIST_FILE = Runner/Info.plist; @@ -960,13 +965,12 @@ isa = XCBuildConfiguration; baseConfigurationReference = 9740EEB21CF90195004384FC /* Debug.xcconfig */; buildSettings = { - ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES; ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; CLANG_ENABLE_MODULES = YES; CODE_SIGN_ENTITLEMENTS = Runner/Runner.entitlements; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 335; + CURRENT_PROJECT_VERSION = 336; DEVELOPMENT_TEAM = 67P82ZQDAS; ENABLE_BITCODE = NO; INFOPLIST_FILE = Runner/Info.plist; @@ -989,13 +993,12 @@ isa = XCBuildConfiguration; baseConfigurationReference = 7AFA3C8E1D35360C0083082E /* Release.xcconfig */; buildSettings = { - ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES; ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; CLANG_ENABLE_MODULES = YES; CODE_SIGN_ENTITLEMENTS = Runner/Runner.entitlements; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 335; + CURRENT_PROJECT_VERSION = 336; DEVELOPMENT_TEAM = 67P82ZQDAS; ENABLE_BITCODE = NO; INFOPLIST_FILE = Runner/Info.plist; @@ -1016,12 +1019,12 @@ /* End XCBuildConfiguration section */ /* Begin XCConfigurationList section */ - 14CD34D82D4F43B300789E6D /* Build configuration list for PBXNativeTarget "Share Extension" */ = { + 142209D82E7AB38000C679DC /* Build configuration list for PBXNativeTarget "Share Extension" */ = { isa = XCConfigurationList; buildConfigurations = ( - 14CD34D92D4F43B300789E6D /* Debug */, - 14CD34DA2D4F43B300789E6D /* Release */, - 14CD34DB2D4F43B300789E6D /* Profile */, + 142209D92E7AB38000C679DC /* Debug */, + 142209DA2E7AB38000C679DC /* Release */, + 142209DB2E7AB38000C679DC /* Profile */, ); defaultConfigurationIsVisible = 0; defaultConfigurationName = Release; diff --git a/ios/Share Extension/RSIShareViewController.swift b/ios/Share Extension/RSIShareViewController.swift new file mode 100644 index 000000000..e13f2ff93 --- /dev/null +++ b/ios/Share Extension/RSIShareViewController.swift @@ -0,0 +1,332 @@ +// +// RSIShareViewController.swift +// receive_sharing_intent +// +// Created by Kasem Mohamed on 2024-01-25. +// + +import UIKit +import Social +import MobileCoreServices +import Photos + +@available(swift, introduced: 5.0) +open class RSIShareViewController: SLComposeServiceViewController { + var hostAppBundleIdentifier = "" + var appGroupId = "" + var sharedMedia: [SharedMediaFile] = [] + + /// Override this method to return false if you don't want to redirect to host app automatically + /// Default is true + open func shouldAutoRedirect() -> Bool { + return true + } + + open override func isContentValid() -> Bool { + return true + } + + open override func viewDidLoad() { + super.viewDidLoad() + + // load group and app id from build info + loadIds() + } + + // Redirect to host app when user click on Post + open override func didSelectPost() { + saveAndRedirect(message: contentText) + } + + open override func viewDidAppear(_ animated: Bool) { + super.viewDidAppear(animated) + + // This is called after the user selects Post. Do the upload of contentText and/or NSExtensionContext attachments. + if let content = extensionContext!.inputItems[0] as? NSExtensionItem { + if let contents = content.attachments { + for (index, attachment) in (contents).enumerated() { + for type in SharedMediaType.allCases { + if attachment.hasItemConformingToTypeIdentifier(type.toUTTypeIdentifier) { + attachment.loadItem(forTypeIdentifier: type.toUTTypeIdentifier) { [weak self] data, error in + guard let this = self, error == nil else { + self?.dismissWithError() + return + } + switch type { + case .text: + if let text = data as? String { + this.handleMedia(forLiteral: text, + type: type, + index: index, + content: content) + } + case .url: + if let url = data as? URL { + this.handleMedia(forLiteral: url.absoluteString, + type: type, + index: index, + content: content) + } + default: + if let url = data as? URL { + this.handleMedia(forFile: url, + type: type, + index: index, + content: content) + } + else if let image = data as? UIImage { + this.handleMedia(forUIImage: image, + type: type, + index: index, + content: content) + } + } + } + break + } + } + } + } + } + } + + open override func configurationItems() -> [Any]! { + // To add configuration options via table cells at the bottom of the sheet, return an array of SLComposeSheetConfigurationItem here. + return [] + } + + private func loadIds() { + // loading Share extension App Id + let shareExtensionAppBundleIdentifier = Bundle.main.bundleIdentifier! + + + // extract host app bundle id from ShareExtension id + // by default it's . + // for example: "com.kasem.sharing.Share-Extension" -> com.kasem.sharing + let lastIndexOfPoint = shareExtensionAppBundleIdentifier.lastIndex(of: ".") + hostAppBundleIdentifier = String(shareExtensionAppBundleIdentifier[.. + let customAppGroupId = Bundle.main.object(forInfoDictionaryKey: kAppGroupIdKey) as? String + + appGroupId = customAppGroupId ?? defaultAppGroupId + } + + + private func handleMedia(forLiteral item: String, type: SharedMediaType, index: Int, content: NSExtensionItem) { + sharedMedia.append(SharedMediaFile( + path: item, + mimeType: type == .text ? "text/plain": nil, + type: type + )) + if index == (content.attachments?.count ?? 0) - 1 { + if shouldAutoRedirect() { + saveAndRedirect() + } + } + } + + private func handleMedia(forUIImage image: UIImage, type: SharedMediaType, index: Int, content: NSExtensionItem){ + let tempPath = FileManager.default.containerURL(forSecurityApplicationGroupIdentifier: appGroupId)!.appendingPathComponent("TempImage.png") + if self.writeTempFile(image, to: tempPath) { + let newPathDecoded = tempPath.absoluteString.removingPercentEncoding! + sharedMedia.append(SharedMediaFile( + path: newPathDecoded, + mimeType: type == .image ? "image/png": nil, + type: type + )) + } + if index == (content.attachments?.count ?? 0) - 1 { + if shouldAutoRedirect() { + saveAndRedirect() + } + } + } + + private func handleMedia(forFile url: URL, type: SharedMediaType, index: Int, content: NSExtensionItem) { + let fileName = getFileName(from: url, type: type) + let newPath = FileManager.default.containerURL(forSecurityApplicationGroupIdentifier: appGroupId)!.appendingPathComponent(fileName) + + if copyFile(at: url, to: newPath) { + // The path should be decoded because Flutter is not expecting url encoded file names + let newPathDecoded = newPath.absoluteString.removingPercentEncoding!; + if type == .video { + // Get video thumbnail and duration + if let videoInfo = getVideoInfo(from: url) { + let thumbnailPathDecoded = videoInfo.thumbnail?.removingPercentEncoding; + sharedMedia.append(SharedMediaFile( + path: newPathDecoded, + mimeType: url.mimeType(), + thumbnail: thumbnailPathDecoded, + duration: videoInfo.duration, + type: type + )) + } + } else { + sharedMedia.append(SharedMediaFile( + path: newPathDecoded, + mimeType: url.mimeType(), + type: type + )) + } + } + + if index == (content.attachments?.count ?? 0) - 1 { + if shouldAutoRedirect() { + saveAndRedirect() + } + } + } + + + // Save shared media and redirect to host app + private func saveAndRedirect(message: String? = nil) { + let userDefaults = UserDefaults(suiteName: appGroupId) + userDefaults?.set(toData(data: sharedMedia), forKey: kUserDefaultsKey) + userDefaults?.set(message, forKey: kUserDefaultsMessageKey) + userDefaults?.synchronize() + redirectToHostApp() + } + + private func redirectToHostApp() { + // ids may not loaded yet so we need loadIds here too + loadIds() + let url = URL(string: "\(kSchemePrefix)-\(hostAppBundleIdentifier):share") + var responder = self as UIResponder? + + if #available(iOS 18.0, *) { + while responder != nil { + if let application = responder as? UIApplication { + application.open(url!, options: [:], completionHandler: nil) + } + responder = responder?.next + } + } else { + let selectorOpenURL = sel_registerName("openURL:") + + while (responder != nil) { + if (responder?.responds(to: selectorOpenURL))! { + _ = responder?.perform(selectorOpenURL, with: url) + } + responder = responder!.next + } + } + + extensionContext!.completeRequest(returningItems: [], completionHandler: nil) + } + + private func dismissWithError() { + print("[ERROR] Error loading data!") + let alert = UIAlertController(title: "Error", message: "Error loading data", preferredStyle: .alert) + + let action = UIAlertAction(title: "Error", style: .cancel) { _ in + self.dismiss(animated: true, completion: nil) + } + + alert.addAction(action) + present(alert, animated: true, completion: nil) + extensionContext!.completeRequest(returningItems: [], completionHandler: nil) + } + + private func getFileName(from url: URL, type: SharedMediaType) -> String { + var name = url.lastPathComponent + if name.isEmpty { + switch type { + case .image: + name = UUID().uuidString + ".png" + case .video: + name = UUID().uuidString + ".mp4" + case .text: + name = UUID().uuidString + ".txt" + default: + name = UUID().uuidString + } + } + return name + } + + private func writeTempFile(_ image: UIImage, to dstURL: URL) -> Bool { + do { + if FileManager.default.fileExists(atPath: dstURL.path) { + try FileManager.default.removeItem(at: dstURL) + } + let pngData = image.pngData(); + try pngData?.write(to: dstURL); + return true; + } catch (let error){ + print("Cannot write to temp file: \(error)"); + return false; + } + } + + private func copyFile(at srcURL: URL, to dstURL: URL) -> Bool { + do { + if FileManager.default.fileExists(atPath: dstURL.path) { + try FileManager.default.removeItem(at: dstURL) + } + try FileManager.default.copyItem(at: srcURL, to: dstURL) + } catch (let error) { + print("Cannot copy item at \(srcURL) to \(dstURL): \(error)") + return false + } + return true + } + + private func getVideoInfo(from url: URL) -> (thumbnail: String?, duration: Double)? { + let asset = AVAsset(url: url) + let duration = (CMTimeGetSeconds(asset.duration) * 1000).rounded() + let thumbnailPath = getThumbnailPath(for: url) + + if FileManager.default.fileExists(atPath: thumbnailPath.path) { + return (thumbnail: thumbnailPath.absoluteString, duration: duration) + } + + var saved = false + let assetImgGenerate = AVAssetImageGenerator(asset: asset) + assetImgGenerate.appliesPreferredTrackTransform = true + // let scale = UIScreen.main.scale + assetImgGenerate.maximumSize = CGSize(width: 360, height: 360) + do { + let img = try assetImgGenerate.copyCGImage(at: CMTimeMakeWithSeconds(600, preferredTimescale: 1), actualTime: nil) + try UIImage(cgImage: img).pngData()?.write(to: thumbnailPath) + saved = true + } catch { + saved = false + } + + return saved ? (thumbnail: thumbnailPath.absoluteString, duration: duration): nil + } + + private func getThumbnailPath(for url: URL) -> URL { + let fileName = Data(url.lastPathComponent.utf8).base64EncodedString().replacingOccurrences(of: "==", with: "") + let path = FileManager.default + .containerURL(forSecurityApplicationGroupIdentifier: appGroupId)! + .appendingPathComponent("\(fileName).jpg") + return path + } + + private func toData(data: [SharedMediaFile]) -> Data { + let encodedData = try? JSONEncoder().encode(data) + return encodedData! + } +} + +extension URL { + public func mimeType() -> String { + if #available(iOS 14.0, *) { + if let mimeType = UTType(filenameExtension: self.pathExtension)?.preferredMIMEType { + return mimeType + } + } else { + if let uti = UTTypeCreatePreferredIdentifierForTag(kUTTagClassFilenameExtension, self.pathExtension as NSString, nil)?.takeRetainedValue() { + if let mimetype = UTTypeCopyPreferredTagWithClass(uti, kUTTagClassMIMEType)?.takeRetainedValue() { + return mimetype as String + } + } + } + + return "application/octet-stream" + } +} diff --git a/ios/Share Extension/ShareViewController.swift b/ios/Share Extension/ShareViewController.swift index 90f1bc8a7..78704fd34 100644 --- a/ios/Share Extension/ShareViewController.swift +++ b/ios/Share Extension/ShareViewController.swift @@ -1,7 +1,7 @@ // If you get no such module 'receive_sharing_intent' error. // Go to Build Phases of your Runner target and // move `Embed Foundation Extension` to the top of `Thin Binary`. -import receive_sharing_intent +//import receive_sharing_intent class ShareViewController: RSIShareViewController { @@ -12,27 +12,3 @@ class ShareViewController: RSIShareViewController { } } - -//import UIKit -//import Social -// -//class ShareViewController: SLComposeServiceViewController { -// -// override func isContentValid() -> Bool { -// // Do validation of contentText and/or NSExtensionContext attachments here -// return true -// } -// -// override func didSelectPost() { -// // This is called after the user selects Post. Do the upload of contentText and/or NSExtensionContext attachments. -// -// // Inform the host that we're done, so it un-blocks its UI. Note: Alternatively you could call super's -didSelectPost, which will similarly complete the extension context. -// self.extensionContext!.completeRequest(returningItems: [], completionHandler: nil) -// } -// -// override func configurationItems() -> [Any]! { -// // To add configuration options via table cells at the bottom of the sheet, return an array of SLComposeSheetConfigurationItem here. -// return [] -// } -// -//} diff --git a/ios/Share Extension/SwiftReceiveSharingIntentPlugin.swift b/ios/Share Extension/SwiftReceiveSharingIntentPlugin.swift new file mode 100644 index 000000000..017ff45e5 --- /dev/null +++ b/ios/Share Extension/SwiftReceiveSharingIntentPlugin.swift @@ -0,0 +1,74 @@ +import UIKit +import Photos + +public let kSchemePrefix = "ShareMedia" +public let kUserDefaultsKey = "ShareKey" +public let kUserDefaultsMessageKey = "ShareMessageKey" +public let kAppGroupIdKey = "AppGroupId" + +public class SharedMediaFile: Codable { + var path: String + var mimeType: String? + var thumbnail: String? // video thumbnail + var duration: Double? // video duration in milliseconds + var message: String? // post message + var type: SharedMediaType + + + public init( + path: String, + mimeType: String? = nil, + thumbnail: String? = nil, + duration: Double? = nil, + message: String?=nil, + type: SharedMediaType) { + self.path = path + self.mimeType = mimeType + self.thumbnail = thumbnail + self.duration = duration + self.message = message + self.type = type + } +} + +public enum SharedMediaType: String, Codable, CaseIterable { + case image + case video + case text +// case audio + case file + case url + + public var toUTTypeIdentifier: String { + if #available(iOS 14.0, *) { + switch self { + case .image: + return UTType.image.identifier + case .video: + return UTType.movie.identifier + case .text: + return UTType.text.identifier + // case .audio: + // return UTType.audio.identifier + case .file: + return UTType.fileURL.identifier + case .url: + return UTType.url.identifier + } + } + switch self { + case .image: + return "public.image" + case .video: + return "public.movie" + case .text: + return "public.text" +// case .audio: +// return "public.audio" + case .file: + return "public.file-url" + case .url: + return "public.url" + } + } +} diff --git a/pubspec.yaml b/pubspec.yaml index 794959de7..693848546 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -16,7 +16,7 @@ publish_to: 'none' # Remove this line if you wish to publish to pub.dev # Read more about iOS versioning at # https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html -version: 1.7.0+335 +version: 1.7.0+336 environment: sdk: ">=2.12.0 <3.0.0" From 182061d660c19c9d727c9d0052b9d0d024e401cb Mon Sep 17 00:00:00 2001 From: Heron Date: Mon, 22 Sep 2025 14:39:46 +0800 Subject: [PATCH 17/47] Update styles --- assets/icons/smile.svg | 6 ++++++ ios/Runner.xcodeproj/project.pbxproj | 6 +++--- lib/components/chat/send_bar.dart | 5 +++-- lib/screens/contact/profile.dart | 8 ++++---- pubspec.yaml | 2 +- 5 files changed, 17 insertions(+), 10 deletions(-) create mode 100644 assets/icons/smile.svg diff --git a/assets/icons/smile.svg b/assets/icons/smile.svg new file mode 100644 index 000000000..6eb9964d7 --- /dev/null +++ b/assets/icons/smile.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/ios/Runner.xcodeproj/project.pbxproj b/ios/Runner.xcodeproj/project.pbxproj index 6bd065202..0d25ba032 100644 --- a/ios/Runner.xcodeproj/project.pbxproj +++ b/ios/Runner.xcodeproj/project.pbxproj @@ -834,7 +834,7 @@ CODE_SIGN_ENTITLEMENTS = Runner/Runner.entitlements; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 336; + CURRENT_PROJECT_VERSION = 337; DEVELOPMENT_TEAM = 67P82ZQDAS; ENABLE_BITCODE = NO; INFOPLIST_FILE = Runner/Info.plist; @@ -970,7 +970,7 @@ CODE_SIGN_ENTITLEMENTS = Runner/Runner.entitlements; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 336; + CURRENT_PROJECT_VERSION = 337; DEVELOPMENT_TEAM = 67P82ZQDAS; ENABLE_BITCODE = NO; INFOPLIST_FILE = Runner/Info.plist; @@ -998,7 +998,7 @@ CODE_SIGN_ENTITLEMENTS = Runner/Runner.entitlements; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 336; + CURRENT_PROJECT_VERSION = 337; DEVELOPMENT_TEAM = 67P82ZQDAS; ENABLE_BITCODE = NO; INFOPLIST_FILE = Runner/Info.plist; diff --git a/lib/components/chat/send_bar.dart b/lib/components/chat/send_bar.dart index 15aefb8e1..16f9ceece 100644 --- a/lib/components/chat/send_bar.dart +++ b/lib/components/chat/send_bar.dart @@ -394,9 +394,10 @@ class _ChatSendBarState extends BaseStateFulWidgetState { behavior: HitTestBehavior.opaque, child: Padding( padding: EdgeInsets.only(right: 8, top: 4), - child: FaIcon( - FontAwesomeIcons.faceSmile, + child: Asset.iconSvg( + 'smile', color: application.theme.primaryColor, + width: 24, ), ), ), diff --git a/lib/screens/contact/profile.dart b/lib/screens/contact/profile.dart index 83aefc8de..f534170b3 100644 --- a/lib/screens/contact/profile.dart +++ b/lib/screens/contact/profile.dart @@ -1039,17 +1039,17 @@ class _ContactProfileScreenState extends BaseStateFulWidgetState[ - Icon(FontAwesomeIcons.userSlash, size: 24, color: application.theme.fallColor), + Icon(FontAwesomeIcons.userSlash, size: 24, color: application.theme.primaryColor), SizedBox(width: 10), Label( Settings.locale((s) => s.block, ctx: context), type: LabelType.bodyRegular, - color: application.theme.fallColor, + color: application.theme.fontColor1, ), Spacer(), CupertinoSwitch( value: _blocked, - activeTrackColor: application.theme.fallColor, + activeTrackColor: application.theme.primaryColor, onChanged: (value) async { setState(() { _blocked = value; @@ -1065,7 +1065,7 @@ class _ContactProfileScreenState extends BaseStateFulWidgetState s.block_tips, ctx: context), type: LabelType.bodySmall, - color: application.theme.fallColor, + color: application.theme.fontColor2, fontWeight: FontWeight.w600, softWrap: true, ), diff --git a/pubspec.yaml b/pubspec.yaml index 693848546..92dc99456 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -16,7 +16,7 @@ publish_to: 'none' # Remove this line if you wish to publish to pub.dev # Read more about iOS versioning at # https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html -version: 1.7.0+336 +version: 1.7.0+337 environment: sdk: ">=2.12.0 <3.0.0" From ca2957a29c7717cfc66eddb734c60ea15026f5d7 Mon Sep 17 00:00:00 2001 From: Heron Date: Tue, 23 Sep 2025 11:36:58 +0800 Subject: [PATCH 18/47] Update styles --- ios/Runner.xcodeproj/project.pbxproj | 10 +--------- lib/components/chat/send_bar.dart | 5 ++--- pubspec.yaml | 2 +- 3 files changed, 4 insertions(+), 13 deletions(-) diff --git a/ios/Runner.xcodeproj/project.pbxproj b/ios/Runner.xcodeproj/project.pbxproj index 0d25ba032..47a7dfb30 100644 --- a/ios/Runner.xcodeproj/project.pbxproj +++ b/ios/Runner.xcodeproj/project.pbxproj @@ -3,7 +3,7 @@ archiveVersion = 1; classes = { }; - objectVersion = 77; + objectVersion = 54; objects = { /* Begin PBXBuildFile section */ @@ -501,14 +501,10 @@ inputFileListPaths = ( "${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-resources-${CONFIGURATION}-input-files.xcfilelist", ); - inputPaths = ( - ); name = "[CP] Copy Pods Resources"; outputFileListPaths = ( "${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-resources-${CONFIGURATION}-output-files.xcfilelist", ); - outputPaths = ( - ); runOnlyForDeploymentPostprocessing = 0; shellPath = /bin/sh; shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-resources.sh\"\n"; @@ -537,14 +533,10 @@ inputFileListPaths = ( "${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks-${CONFIGURATION}-input-files.xcfilelist", ); - inputPaths = ( - ); name = "[CP] Embed Pods Frameworks"; outputFileListPaths = ( "${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks-${CONFIGURATION}-output-files.xcfilelist", ); - outputPaths = ( - ); runOnlyForDeploymentPostprocessing = 0; shellPath = /bin/sh; shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks.sh\"\n"; diff --git a/lib/components/chat/send_bar.dart b/lib/components/chat/send_bar.dart index 16f9ceece..983de2916 100644 --- a/lib/components/chat/send_bar.dart +++ b/lib/components/chat/send_bar.dart @@ -394,10 +394,9 @@ class _ChatSendBarState extends BaseStateFulWidgetState { behavior: HitTestBehavior.opaque, child: Padding( padding: EdgeInsets.only(right: 8, top: 4), - child: Asset.iconSvg( - 'smile', + child: FaIcon( + FontAwesomeIcons.file, color: application.theme.primaryColor, - width: 24, ), ), ), diff --git a/pubspec.yaml b/pubspec.yaml index 92dc99456..215bb2ca5 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -16,7 +16,7 @@ publish_to: 'none' # Remove this line if you wish to publish to pub.dev # Read more about iOS versioning at # https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html -version: 1.7.0+337 +version: 1.7.0+338 environment: sdk: ">=2.12.0 <3.0.0" From 5e38fcc75871541dfcf1f0749dcf915ac704b63e Mon Sep 17 00:00:00 2001 From: Heron Date: Wed, 24 Sep 2025 15:08:11 +0800 Subject: [PATCH 19/47] Fix profile --- lib/components/chat/message_item.dart | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/lib/components/chat/message_item.dart b/lib/components/chat/message_item.dart index f1e90de88..d2759e0f9 100644 --- a/lib/components/chat/message_item.dart +++ b/lib/components/chat/message_item.dart @@ -366,7 +366,8 @@ class _ChatMessageItemState extends BaseStateFulWidgetState { type: LabelType.bodyRegular, ), onTap: () { - ContactProfileScreen.go(context, schema: this._sender); + final String peerAddress = widget.message.isOutbound ? widget.message.targetId : widget.message.sender; + ContactProfileScreen.go(context, address: peerAddress); }, ), ], From 229ace7c0d2c30ac5aa49fb0996b3f11d123d6aa Mon Sep 17 00:00:00 2001 From: Heron Date: Wed, 24 Sep 2025 16:10:50 +0800 Subject: [PATCH 20/47] feat(share): replace receive_sharing_intent with share_handler --- android/app/src/main/AndroidManifest.xml | 44 +-- ios/Podfile | 7 +- ios/Podfile.lock | 24 +- ios/Runner.xcodeproj/project.pbxproj | 129 +++---- ios/Runner/AppDelegate.swift | 55 +-- ios/Runner/Info.plist | 23 +- ios/Share Extension/Info.plist | 46 --- .../RSIShareViewController.swift | 332 ------------------ ios/Share Extension/ShareViewController.swift | 14 - .../SwiftReceiveSharingIntentPlugin.swift | 74 ---- .../Base.lproj/MainInterface.storyboard | 0 ios/ShareExtension/Info.plist | 53 +++ .../ShareExtension.entitlements} | 0 ios/ShareExtension/ShareViewController.swift | 3 + lib/app.dart | 34 +- lib/helpers/share.dart | 114 +++--- lib/main.dart | 2 +- pubspec.lock | 40 ++- pubspec.yaml | 4 +- 19 files changed, 299 insertions(+), 699 deletions(-) delete mode 100644 ios/Share Extension/Info.plist delete mode 100644 ios/Share Extension/RSIShareViewController.swift delete mode 100644 ios/Share Extension/ShareViewController.swift delete mode 100644 ios/Share Extension/SwiftReceiveSharingIntentPlugin.swift rename ios/{Share Extension => ShareExtension}/Base.lproj/MainInterface.storyboard (100%) create mode 100644 ios/ShareExtension/Info.plist rename ios/{Share Extension/Share Extension.entitlements => ShareExtension/ShareExtension.entitlements} (100%) create mode 100644 ios/ShareExtension/ShareViewController.swift diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index 6fb3216ec..b47803a51 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -101,33 +101,27 @@ - - - - - - - - - + + + + + + @@ -138,16 +132,18 @@ - - - - - - - - - - + + + + + + + + + + + + @@ -177,10 +173,6 @@ android:name="com.google.firebase.messaging.default_notification_icon" android:resource="@mipmap/ic_launcher_round" /> - - - - diff --git a/ios/Podfile b/ios/Podfile index 96063a38a..6d741768e 100644 --- a/ios/Podfile +++ b/ios/Podfile @@ -35,9 +35,12 @@ target 'Runner' do pod 'Firebase/Analytics' flutter_install_all_ios_pods File.dirname(File.realpath(__FILE__)) - target 'Share Extension' do - inherit! :search_paths + # share_handler addition start + target 'ShareExtension' do + inherit! :search_paths + pod "share_handler_ios_models", :path => ".symlinks/plugins/share_handler_ios/ios/Models" end + # share_handler addition end end post_install do |installer| diff --git a/ios/Podfile.lock b/ios/Podfile.lock index 98bed7b50..dd3f6e3e0 100644 --- a/ios/Podfile.lock +++ b/ios/Podfile.lock @@ -194,8 +194,6 @@ PODS: - qr_code_scanner (0.2.0): - Flutter - MTBBarcodeScanner - - receive_sharing_intent (1.8.1): - - Flutter - SDWebImage (5.21.2): - SDWebImage/Core (= 5.21.2) - SDWebImage/Core (5.21.2) @@ -207,6 +205,14 @@ PODS: - Flutter - FlutterMacOS - Sentry/HybridSDK (= 8.46.0) + - share_handler_ios (0.0.14): + - Flutter + - share_handler_ios/share_handler_ios_models (= 0.0.14) + - share_handler_ios_models + - share_handler_ios/share_handler_ios_models (0.0.14): + - Flutter + - share_handler_ios_models + - share_handler_ios_models (0.0.9) - share_plus (0.0.1): - Flutter - shared_preferences_foundation (0.0.1): @@ -264,8 +270,9 @@ DEPENDENCIES: - path_provider_foundation (from `.symlinks/plugins/path_provider_foundation/darwin`) - permission_handler_apple (from `.symlinks/plugins/permission_handler_apple/ios`) - qr_code_scanner (from `.symlinks/plugins/qr_code_scanner/ios`) - - receive_sharing_intent (from `.symlinks/plugins/receive_sharing_intent/ios`) - sentry_flutter (from `.symlinks/plugins/sentry_flutter/ios`) + - share_handler_ios (from `.symlinks/plugins/share_handler_ios/ios`) + - share_handler_ios_models (from `.symlinks/plugins/share_handler_ios/ios/Models`) - share_plus (from `.symlinks/plugins/share_plus/ios`) - shared_preferences_foundation (from `.symlinks/plugins/shared_preferences_foundation/darwin`) - sqflite (from `.symlinks/plugins/sqflite/ios`) @@ -345,10 +352,12 @@ EXTERNAL SOURCES: :path: ".symlinks/plugins/permission_handler_apple/ios" qr_code_scanner: :path: ".symlinks/plugins/qr_code_scanner/ios" - receive_sharing_intent: - :path: ".symlinks/plugins/receive_sharing_intent/ios" sentry_flutter: :path: ".symlinks/plugins/sentry_flutter/ios" + share_handler_ios: + :path: ".symlinks/plugins/share_handler_ios/ios" + share_handler_ios_models: + :path: ".symlinks/plugins/share_handler_ios/ios/Models" share_plus: :path: ".symlinks/plugins/share_plus/ios" shared_preferences_foundation: @@ -407,11 +416,12 @@ SPEC CHECKSUMS: permission_handler_apple: 4ed2196e43d0651e8ff7ca3483a069d469701f2d PromisesObjC: f5707f49cb48b9636751c5b2e7d227e43fba9f47 qr_code_scanner: d77f94ecc9abf96d9b9b8fc04ef13f611e5a147a - receive_sharing_intent: 222384f00ffe7e952bbfabaa9e3967cb87e5fe00 SDWebImage: 9f177d83116802728e122410fb25ad88f5c7608a SDWebImageWebPCoder: e38c0a70396191361d60c092933e22c20d5b1380 Sentry: da60d980b197a46db0b35ea12cb8f39af48d8854 sentry_flutter: 27892878729f42701297c628eb90e7c6529f3684 + share_handler_ios: e2244e990f826b2c8eaa291ac3831569438ba0fb + share_handler_ios_models: fc638c9b4330dc7f082586c92aee9dfa0b87b871 share_plus: 50da8cb520a8f0f65671c6c6a99b3617ed10a58a shared_preferences_foundation: 9e1978ff2562383bd5676f64ec4e9aa8fa06a6f7 sqflite: 24cf5e59c24c79123ad6d3d14e486d02a94e676b @@ -425,6 +435,6 @@ SPEC CHECKSUMS: video_thumbnail: b637e0ad5f588ca9945f6e2c927f73a69a661140 webview_flutter_wkwebview: 1821ceac936eba6f7984d89a9f3bcb4dea99ebb2 -PODFILE CHECKSUM: 60460378a4f427ae927a7ca3a726e6ce0c9e3b98 +PODFILE CHECKSUM: ccfab3afb7c1569fe4d39ff1e877dd45b28d84c6 COCOAPODS: 1.16.2 diff --git a/ios/Runner.xcodeproj/project.pbxproj b/ios/Runner.xcodeproj/project.pbxproj index 47a7dfb30..465bf6fce 100644 --- a/ios/Runner.xcodeproj/project.pbxproj +++ b/ios/Runner.xcodeproj/project.pbxproj @@ -7,13 +7,13 @@ objects = { /* Begin PBXBuildFile section */ - 0CEE64841036599BB324F1CC /* Pods_Share_Extension.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 9C257DECA1497057748992AF /* Pods_Share_Extension.framework */; }; - 142209D62E7AB38000C679DC /* Share Extension.appex in Embed App Extensions */ = {isa = PBXBuildFile; fileRef = 142209CC2E7AB38000C679DC /* Share Extension.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; }; + 147F890F2E83CBC000BFD48B /* ShareExtension.appex in Embed App Extensions */ = {isa = PBXBuildFile; fileRef = 147F89052E83CBC000BFD48B /* ShareExtension.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; }; 148FFB1A2E60499C005410D0 /* GoogleService-Info.plist in Resources */ = {isa = PBXBuildFile; fileRef = 148FFB192E60499C005410D0 /* GoogleService-Info.plist */; }; 1498D2341E8E89220040F4C2 /* GeneratedPluginRegistrant.m in Sources */ = {isa = PBXBuildFile; fileRef = 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */; }; 149BC80728FEAEB000D27A6D /* DnsResolver.swift in Sources */ = {isa = PBXBuildFile; fileRef = 149BC80628FEAEB000D27A6D /* DnsResolver.swift */; }; 14A871CE287D6DB00093692A /* EthResolver.swift in Sources */ = {isa = PBXBuildFile; fileRef = 14A871CD287D6DAF0093692A /* EthResolver.swift */; }; 2965BED301999E2B8AAC496C /* Pods_Runner.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 334D81B7E27D6131B561C943 /* Pods_Runner.framework */; }; + 38534CB55C06EB49E66DECD2 /* Pods_ShareExtension.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 6CB8FE12826229F00614A342 /* Pods_ShareExtension.framework */; }; 3B3967161E833CAA004F5970 /* AppFrameworkInfo.plist in Resources */ = {isa = PBXBuildFile; fileRef = 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */; }; 74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 74858FAE1ED2DC5600515810 /* AppDelegate.swift */; }; 97C146FC1CF9000F007C117D /* Main.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FA1CF9000F007C117D /* Main.storyboard */; }; @@ -44,12 +44,12 @@ /* End PBXBuildFile section */ /* Begin PBXContainerItemProxy section */ - 142209D42E7AB38000C679DC /* PBXContainerItemProxy */ = { + 147F890D2E83CBC000BFD48B /* PBXContainerItemProxy */ = { isa = PBXContainerItemProxy; containerPortal = 97C146E61CF9000F007C117D /* Project object */; proxyType = 1; - remoteGlobalIDString = 142209CB2E7AB38000C679DC; - remoteInfo = "Share Extension"; + remoteGlobalIDString = 147F89042E83CBC000BFD48B; + remoteInfo = ShareExtension; }; /* End PBXContainerItemProxy section */ @@ -70,7 +70,7 @@ dstPath = ""; dstSubfolderSpec = 13; files = ( - 142209D62E7AB38000C679DC /* Share Extension.appex in Embed App Extensions */, + 147F890F2E83CBC000BFD48B /* ShareExtension.appex in Embed App Extensions */, ); name = "Embed App Extensions"; runOnlyForDeploymentPostprocessing = 0; @@ -81,7 +81,7 @@ 02EED84DF132B94A356E3DA4 /* Pods-Share Extension.profile.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Share Extension.profile.xcconfig"; path = "Target Support Files/Pods-Share Extension/Pods-Share Extension.profile.xcconfig"; sourceTree = ""; }; 0C98E54980A890E1BD057FA6 /* Pods-Runner.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.debug.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.debug.xcconfig"; sourceTree = ""; }; 142209AC2E7AB14500C679DC /* receive_sharing_intent.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; path = receive_sharing_intent.framework; sourceTree = BUILT_PRODUCTS_DIR; }; - 142209CC2E7AB38000C679DC /* Share Extension.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = "Share Extension.appex"; sourceTree = BUILT_PRODUCTS_DIR; }; + 147F89052E83CBC000BFD48B /* ShareExtension.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = ShareExtension.appex; sourceTree = BUILT_PRODUCTS_DIR; }; 148FFB192E60499C005410D0 /* GoogleService-Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = "GoogleService-Info.plist"; sourceTree = ""; }; 1498D2321E8E86230040F4C2 /* GeneratedPluginRegistrant.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = GeneratedPluginRegistrant.h; sourceTree = ""; }; 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = GeneratedPluginRegistrant.m; sourceTree = ""; }; @@ -90,9 +90,13 @@ 2BD846195C7A4EF53B2AAABB /* Pods-Runner.profile.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.profile.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.profile.xcconfig"; sourceTree = ""; }; 334D81B7E27D6131B561C943 /* Pods_Runner.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_Runner.framework; sourceTree = BUILT_PRODUCTS_DIR; }; 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; name = AppFrameworkInfo.plist; path = Flutter/AppFrameworkInfo.plist; sourceTree = ""; }; + 3C6D0BD6B5E0E014F40856BD /* Pods-ShareExtension.profile.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-ShareExtension.profile.xcconfig"; path = "Target Support Files/Pods-ShareExtension/Pods-ShareExtension.profile.xcconfig"; sourceTree = ""; }; + 584544DF38FA171ACDCA775B /* Pods-ShareExtension.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-ShareExtension.debug.xcconfig"; path = "Target Support Files/Pods-ShareExtension/Pods-ShareExtension.debug.xcconfig"; sourceTree = ""; }; + 6CB8FE12826229F00614A342 /* Pods_ShareExtension.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_ShareExtension.framework; sourceTree = BUILT_PRODUCTS_DIR; }; 74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "Runner-Bridging-Header.h"; sourceTree = ""; }; 74858FAE1ED2DC5600515810 /* AppDelegate.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = ""; }; 7AFA3C8E1D35360C0083082E /* Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; name = Release.xcconfig; path = Flutter/Release.xcconfig; sourceTree = ""; }; + 7F6144DC7E3F6B8544F0EE4F /* Pods-ShareExtension.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-ShareExtension.release.xcconfig"; path = "Target Support Files/Pods-ShareExtension/Pods-ShareExtension.release.xcconfig"; sourceTree = ""; }; 8AF95113A5629FC499DAE55F /* Pods-Runner.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.release.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.release.xcconfig"; sourceTree = ""; }; 9740EEB21CF90195004384FC /* Debug.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; name = Debug.xcconfig; path = Flutter/Debug.xcconfig; sourceTree = ""; }; 9740EEB31CF90195004384FC /* Generated.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; name = Generated.xcconfig; path = Flutter/Generated.xcconfig; sourceTree = ""; }; @@ -140,32 +144,32 @@ /* End PBXFileReference section */ /* Begin PBXFileSystemSynchronizedBuildFileExceptionSet section */ - 142209D72E7AB38000C679DC /* Exceptions for "Share Extension" folder in "Share Extension" target */ = { + 147F89132E83CBC000BFD48B /* Exceptions for "ShareExtension" folder in "ShareExtension" target */ = { isa = PBXFileSystemSynchronizedBuildFileExceptionSet; membershipExceptions = ( Info.plist, ); - target = 142209CB2E7AB38000C679DC /* Share Extension */; + target = 147F89042E83CBC000BFD48B /* ShareExtension */; }; /* End PBXFileSystemSynchronizedBuildFileExceptionSet section */ /* Begin PBXFileSystemSynchronizedRootGroup section */ - 142209CD2E7AB38000C679DC /* Share Extension */ = { + 147F89062E83CBC000BFD48B /* ShareExtension */ = { isa = PBXFileSystemSynchronizedRootGroup; exceptions = ( - 142209D72E7AB38000C679DC /* Exceptions for "Share Extension" folder in "Share Extension" target */, + 147F89132E83CBC000BFD48B /* Exceptions for "ShareExtension" folder in "ShareExtension" target */, ); - path = "Share Extension"; + path = ShareExtension; sourceTree = ""; }; /* End PBXFileSystemSynchronizedRootGroup section */ /* Begin PBXFrameworksBuildPhase section */ - 142209C92E7AB38000C679DC /* Frameworks */ = { + 147F89022E83CBC000BFD48B /* Frameworks */ = { isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( - 0CEE64841036599BB324F1CC /* Pods_Share_Extension.framework in Frameworks */, + 38534CB55C06EB49E66DECD2 /* Pods_ShareExtension.framework in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -206,6 +210,9 @@ EA4800B8A7D823487FC27079 /* Pods-Share Extension.debug.xcconfig */, BD68F6F0F500A9479AA449AA /* Pods-Share Extension.release.xcconfig */, 02EED84DF132B94A356E3DA4 /* Pods-Share Extension.profile.xcconfig */, + 584544DF38FA171ACDCA775B /* Pods-ShareExtension.debug.xcconfig */, + 7F6144DC7E3F6B8544F0EE4F /* Pods-ShareExtension.release.xcconfig */, + 3C6D0BD6B5E0E014F40856BD /* Pods-ShareExtension.profile.xcconfig */, ); path = Pods; sourceTree = ""; @@ -216,6 +223,7 @@ 142209AC2E7AB14500C679DC /* receive_sharing_intent.framework */, 334D81B7E27D6131B561C943 /* Pods_Runner.framework */, 9C257DECA1497057748992AF /* Pods_Share_Extension.framework */, + 6CB8FE12826229F00614A342 /* Pods_ShareExtension.framework */, ); name = Frameworks; sourceTree = ""; @@ -237,7 +245,7 @@ C61A6E6D266A19F000A7F1E1 /* Classes */, 9740EEB11CF90186004384FC /* Flutter */, 97C146F01CF9000F007C117D /* Runner */, - 142209CD2E7AB38000C679DC /* Share Extension */, + 147F89062E83CBC000BFD48B /* ShareExtension */, 97C146EF1CF9000F007C117D /* Products */, 3D6E1C9BD273F4DA4DBFC297 /* Pods */, 594E66BF7E7D2CAB5FC62094 /* Frameworks */, @@ -248,7 +256,7 @@ isa = PBXGroup; children = ( 97C146EE1CF9000F007C117D /* Runner.app */, - 142209CC2E7AB38000C679DC /* Share Extension.appex */, + 147F89052E83CBC000BFD48B /* ShareExtension.appex */, ); name = Products; sourceTree = ""; @@ -345,25 +353,25 @@ /* End PBXGroup section */ /* Begin PBXNativeTarget section */ - 142209CB2E7AB38000C679DC /* Share Extension */ = { + 147F89042E83CBC000BFD48B /* ShareExtension */ = { isa = PBXNativeTarget; - buildConfigurationList = 142209D82E7AB38000C679DC /* Build configuration list for PBXNativeTarget "Share Extension" */; + buildConfigurationList = 147F89142E83CBC000BFD48B /* Build configuration list for PBXNativeTarget "ShareExtension" */; buildPhases = ( - C02F37618FCF833CBE8CCC80 /* [CP] Check Pods Manifest.lock */, - 142209C82E7AB38000C679DC /* Sources */, - 142209C92E7AB38000C679DC /* Frameworks */, - 142209CA2E7AB38000C679DC /* Resources */, + F9E650D65F4A45AFD5FC8C65 /* [CP] Check Pods Manifest.lock */, + 147F89012E83CBC000BFD48B /* Sources */, + 147F89022E83CBC000BFD48B /* Frameworks */, + 147F89032E83CBC000BFD48B /* Resources */, ); buildRules = ( ); dependencies = ( ); fileSystemSynchronizedGroups = ( - 142209CD2E7AB38000C679DC /* Share Extension */, + 147F89062E83CBC000BFD48B /* ShareExtension */, ); - name = "Share Extension"; - productName = "Share Extension"; - productReference = 142209CC2E7AB38000C679DC /* Share Extension.appex */; + name = ShareExtension; + productName = ShareExtension; + productReference = 147F89052E83CBC000BFD48B /* ShareExtension.appex */; productType = "com.apple.product-type.app-extension"; }; 97C146ED1CF9000F007C117D /* Runner */ = { @@ -384,7 +392,7 @@ buildRules = ( ); dependencies = ( - 142209D52E7AB38000C679DC /* PBXTargetDependency */, + 147F890E2E83CBC000BFD48B /* PBXTargetDependency */, ); name = Runner; productName = Runner; @@ -401,8 +409,8 @@ LastUpgradeCheck = 1510; ORGANIZATIONNAME = ""; TargetAttributes = { - 142209CB2E7AB38000C679DC = { - CreatedOnToolsVersion = 26.0; + 147F89042E83CBC000BFD48B = { + CreatedOnToolsVersion = 26.0.1; }; 97C146ED1CF9000F007C117D = { CreatedOnToolsVersion = 7.3.1; @@ -424,13 +432,13 @@ projectRoot = ""; targets = ( 97C146ED1CF9000F007C117D /* Runner */, - 142209CB2E7AB38000C679DC /* Share Extension */, + 147F89042E83CBC000BFD48B /* ShareExtension */, ); }; /* End PBXProject section */ /* Begin PBXResourcesBuildPhase section */ - 142209CA2E7AB38000C679DC /* Resources */ = { + 147F89032E83CBC000BFD48B /* Resources */ = { isa = PBXResourcesBuildPhase; buildActionMask = 2147483647; files = ( @@ -542,7 +550,7 @@ shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks.sh\"\n"; showEnvVarsInLog = 0; }; - C02F37618FCF833CBE8CCC80 /* [CP] Check Pods Manifest.lock */ = { + F9E650D65F4A45AFD5FC8C65 /* [CP] Check Pods Manifest.lock */ = { isa = PBXShellScriptBuildPhase; buildActionMask = 2147483647; files = ( @@ -557,7 +565,7 @@ outputFileListPaths = ( ); outputPaths = ( - "$(DERIVED_FILE_DIR)/Pods-Share Extension-checkManifestLockResult.txt", + "$(DERIVED_FILE_DIR)/Pods-ShareExtension-checkManifestLockResult.txt", ); runOnlyForDeploymentPostprocessing = 0; shellPath = /bin/sh; @@ -567,7 +575,7 @@ /* End PBXShellScriptBuildPhase section */ /* Begin PBXSourcesBuildPhase section */ - 142209C82E7AB38000C679DC /* Sources */ = { + 147F89012E83CBC000BFD48B /* Sources */ = { isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( @@ -607,10 +615,10 @@ /* End PBXSourcesBuildPhase section */ /* Begin PBXTargetDependency section */ - 142209D52E7AB38000C679DC /* PBXTargetDependency */ = { + 147F890E2E83CBC000BFD48B /* PBXTargetDependency */ = { isa = PBXTargetDependency; - target = 142209CB2E7AB38000C679DC /* Share Extension */; - targetProxy = 142209D42E7AB38000C679DC /* PBXContainerItemProxy */; + target = 147F89042E83CBC000BFD48B /* ShareExtension */; + targetProxy = 147F890D2E83CBC000BFD48B /* PBXContainerItemProxy */; }; /* End PBXTargetDependency section */ @@ -634,9 +642,9 @@ /* End PBXVariantGroup section */ /* Begin XCBuildConfiguration section */ - 142209D92E7AB38000C679DC /* Debug */ = { + 147F89102E83CBC000BFD48B /* Debug */ = { isa = XCBuildConfiguration; - baseConfigurationReference = EA4800B8A7D823487FC27079 /* Pods-Share Extension.debug.xcconfig */; + baseConfigurationReference = 584544DF38FA171ACDCA775B /* Pods-ShareExtension.debug.xcconfig */; buildSettings = { ALWAYS_SEARCH_USER_PATHS = NO; ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; @@ -646,6 +654,7 @@ CLANG_WARN_DOCUMENTATION_COMMENTS = YES; CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; + CODE_SIGN_ENTITLEMENTS = ShareExtension/ShareExtension.entitlements; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; @@ -653,10 +662,10 @@ ENABLE_USER_SCRIPT_SANDBOXING = YES; GCC_C_LANGUAGE_STANDARD = gnu17; GENERATE_INFOPLIST_FILE = YES; - INFOPLIST_FILE = "Share Extension/Info.plist"; - INFOPLIST_KEY_CFBundleDisplayName = "Share Extension"; + INFOPLIST_FILE = ShareExtension/Info.plist; + INFOPLIST_KEY_CFBundleDisplayName = ShareExtension; INFOPLIST_KEY_NSHumanReadableCopyright = ""; - IPHONEOS_DEPLOYMENT_TARGET = 26.0; + IPHONEOS_DEPLOYMENT_TARGET = 14.0; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", @@ -666,7 +675,7 @@ MARKETING_VERSION = 1.0; MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; MTL_FAST_MATH = YES; - PRODUCT_BUNDLE_IDENTIFIER = "org.nkn.nmobile.Share-Extension"; + PRODUCT_BUNDLE_IDENTIFIER = org.nkn.nmobile.ShareExtension; PRODUCT_NAME = "$(TARGET_NAME)"; SKIP_INSTALL = YES; STRING_CATALOG_GENERATE_SYMBOLS = YES; @@ -680,9 +689,9 @@ }; name = Debug; }; - 142209DA2E7AB38000C679DC /* Release */ = { + 147F89112E83CBC000BFD48B /* Release */ = { isa = XCBuildConfiguration; - baseConfigurationReference = BD68F6F0F500A9479AA449AA /* Pods-Share Extension.release.xcconfig */; + baseConfigurationReference = 7F6144DC7E3F6B8544F0EE4F /* Pods-ShareExtension.release.xcconfig */; buildSettings = { ALWAYS_SEARCH_USER_PATHS = NO; ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; @@ -692,6 +701,7 @@ CLANG_WARN_DOCUMENTATION_COMMENTS = YES; CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; + CODE_SIGN_ENTITLEMENTS = ShareExtension/ShareExtension.entitlements; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; @@ -699,10 +709,10 @@ ENABLE_USER_SCRIPT_SANDBOXING = YES; GCC_C_LANGUAGE_STANDARD = gnu17; GENERATE_INFOPLIST_FILE = YES; - INFOPLIST_FILE = "Share Extension/Info.plist"; - INFOPLIST_KEY_CFBundleDisplayName = "Share Extension"; + INFOPLIST_FILE = ShareExtension/Info.plist; + INFOPLIST_KEY_CFBundleDisplayName = ShareExtension; INFOPLIST_KEY_NSHumanReadableCopyright = ""; - IPHONEOS_DEPLOYMENT_TARGET = 26.0; + IPHONEOS_DEPLOYMENT_TARGET = 14.0; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", @@ -711,7 +721,7 @@ LOCALIZATION_PREFERS_STRING_CATALOGS = YES; MARKETING_VERSION = 1.0; MTL_FAST_MATH = YES; - PRODUCT_BUNDLE_IDENTIFIER = "org.nkn.nmobile.Share-Extension"; + PRODUCT_BUNDLE_IDENTIFIER = org.nkn.nmobile.ShareExtension; PRODUCT_NAME = "$(TARGET_NAME)"; SKIP_INSTALL = YES; STRING_CATALOG_GENERATE_SYMBOLS = YES; @@ -723,9 +733,9 @@ }; name = Release; }; - 142209DB2E7AB38000C679DC /* Profile */ = { + 147F89122E83CBC000BFD48B /* Profile */ = { isa = XCBuildConfiguration; - baseConfigurationReference = 02EED84DF132B94A356E3DA4 /* Pods-Share Extension.profile.xcconfig */; + baseConfigurationReference = 3C6D0BD6B5E0E014F40856BD /* Pods-ShareExtension.profile.xcconfig */; buildSettings = { ALWAYS_SEARCH_USER_PATHS = NO; ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; @@ -735,6 +745,7 @@ CLANG_WARN_DOCUMENTATION_COMMENTS = YES; CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; + CODE_SIGN_ENTITLEMENTS = ShareExtension/ShareExtension.entitlements; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; @@ -742,10 +753,10 @@ ENABLE_USER_SCRIPT_SANDBOXING = YES; GCC_C_LANGUAGE_STANDARD = gnu17; GENERATE_INFOPLIST_FILE = YES; - INFOPLIST_FILE = "Share Extension/Info.plist"; - INFOPLIST_KEY_CFBundleDisplayName = "Share Extension"; + INFOPLIST_FILE = ShareExtension/Info.plist; + INFOPLIST_KEY_CFBundleDisplayName = ShareExtension; INFOPLIST_KEY_NSHumanReadableCopyright = ""; - IPHONEOS_DEPLOYMENT_TARGET = 26.0; + IPHONEOS_DEPLOYMENT_TARGET = 14.0; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", @@ -754,7 +765,7 @@ LOCALIZATION_PREFERS_STRING_CATALOGS = YES; MARKETING_VERSION = 1.0; MTL_FAST_MATH = YES; - PRODUCT_BUNDLE_IDENTIFIER = "org.nkn.nmobile.Share-Extension"; + PRODUCT_BUNDLE_IDENTIFIER = org.nkn.nmobile.ShareExtension; PRODUCT_NAME = "$(TARGET_NAME)"; SKIP_INSTALL = YES; STRING_CATALOG_GENERATE_SYMBOLS = YES; @@ -1011,12 +1022,12 @@ /* End XCBuildConfiguration section */ /* Begin XCConfigurationList section */ - 142209D82E7AB38000C679DC /* Build configuration list for PBXNativeTarget "Share Extension" */ = { + 147F89142E83CBC000BFD48B /* Build configuration list for PBXNativeTarget "ShareExtension" */ = { isa = XCConfigurationList; buildConfigurations = ( - 142209D92E7AB38000C679DC /* Debug */, - 142209DA2E7AB38000C679DC /* Release */, - 142209DB2E7AB38000C679DC /* Profile */, + 147F89102E83CBC000BFD48B /* Debug */, + 147F89112E83CBC000BFD48B /* Release */, + 147F89122E83CBC000BFD48B /* Profile */, ); defaultConfigurationIsVisible = 0; defaultConfigurationName = Release; diff --git a/ios/Runner/AppDelegate.swift b/ios/Runner/AppDelegate.swift index 32e2e0acb..01b35a855 100644 --- a/ios/Runner/AppDelegate.swift +++ b/ios/Runner/AppDelegate.swift @@ -1,7 +1,6 @@ import UIKit import Flutter import Firebase -import receive_sharing_intent @main @objc class AppDelegate: FlutterAppDelegate { @@ -26,25 +25,13 @@ import receive_sharing_intent Crypto.register(controller: controller) EthResolver.register(controller: controller) DnsResolver.register(controller: controller) - + registerNotification(); - - // NotificationCenter.default.addObserver(self, selector:#selector(becomeActive), name: UIApplication.didBecomeActiveNotification, object: nil) - // NotificationCenter.default.addObserver(self, selector:#selector(becomeDeath), name: UIApplication.didEnterBackgroundNotification, object: nil) - + return super.application(application, didFinishLaunchingWithOptions: launchOptions) - // return true; // FIXED: with no share data } override func application(_ app: UIApplication, open url: URL, options: [UIApplication.OpenURLOptionsKey : Any] = [:]) -> Bool { - let sharingIntent = SwiftReceiveSharingIntentPlugin.instance - if sharingIntent.hasMatchingSchemePrefix(url: url) { - return sharingIntent.application(app, open: url, options: options) - } - - // For example - // return MSALPublicClientApplication.handleMSALResponse(url, sourceApplication: options[.sourceApplication] as? String) - // return false return super.application(app, open: url, options:options) } @@ -70,36 +57,13 @@ import receive_sharing_intent } } -// @objc func becomeActive(noti:Notification) { -// //APNSPushService.shared().connectAPNS() -// } - -// @objc func becomeDeath(noti:Notification) { -// APNSPushService.shared().disConnectAPNS() -// } - override func application(_ application: UIApplication, didRegisterForRemoteNotificationsWithDeviceToken deviceToken: Data) { // deviceToken = 32 bytes let formatDeviceToken = deviceToken.map { String(format: "%02.2hhx", arguments: [$0]) }.joined() print("Application - GetDeviceToken - token = \(formatDeviceToken)") UserDefaults.standard.setValue(formatDeviceToken, forKey: "nkn_device_token") - -// DispatchQueue.main.asyncAfter(deadline: DispatchTime.now() + 5) { -// APNSPushService.shared().connectAPNS(); -// } } -// override func application(_ application: UIApplication, didReceiveRemoteNotification userInfo: [AnyHashable : Any], fetchCompletionHandler completionHandler: @escaping (UIBackgroundFetchResult) -> Void) { -// print("Application - didReceiveRemoteNotification - onReceive - userInfo = \(userInfo)") -// let aps = userInfo["aps"] as? [String: Any] -// let alert = aps?["alert"] as? [String: Any] -// var resultMap: [String: Any] = [String: Any]() -// resultMap["title"] = alert?["title"] -// resultMap["content"] = alert?["body"] -// resultMap["isApplicationForeground"] = application.applicationState == UIApplication.State.active -// Common.eventAdd(name: "onRemoteMessageReceived", map: resultMap) -// } - override func userNotificationCenter(_ center: UNUserNotificationCenter, willPresent notification: UNNotification, withCompletionHandler completionHandler: @escaping (UNNotificationPresentationOptions) -> Void) { let userInfo = notification.request.content.userInfo @@ -111,25 +75,10 @@ import receive_sharing_intent resultMap["content"] = alert?["body"] resultMap["isApplicationForeground"] = UIApplication.shared.applicationState == UIApplication.State.active Common.eventAdd(name: "onRemoteMessageReceived", map: resultMap) - // completionHandler([.alert, .badge, .sound]) // show notification on flutter, not here } -// override func userNotificationCenter(_ center: UNUserNotificationCenter, didReceive response: UNNotificationResponse, withCompletionHandler completionHandler: @escaping () -> Void) { -// let userInfo = response.notification.request.content.userInfo -// print("Application - userNotificationCenter - onClick - userInfo = \(userInfo)") -// let aps = userInfo["aps"] as? [String: Any] -// let alert = aps?["alert"] as? [String: Any] -// var resultMap: [String: Any] = [String: Any]() -// resultMap["title"] = alert?["title"] -// resultMap["content"] = alert?["body"] -// resultMap["isApplicationForeground"] = UIApplication.shared.applicationState == UIApplication.State.active -// Common.eventAdd(name: "onNotificationClick", map: resultMap) -// completionHandler() -// } - override func applicationWillResignActive(_ application: UIApplication) { window?.addSubview(self.visualEffectView) - } override func applicationDidEnterBackground(_ application: UIApplication) { diff --git a/ios/Runner/Info.plist b/ios/Runner/Info.plist index ed86aab98..3973f6c11 100644 --- a/ios/Runner/Info.plist +++ b/ios/Runner/Info.plist @@ -4,20 +4,19 @@ AppGroupId $(CUSTOM_GROUP_ID) + CFBundleURLTypes CFBundleTypeRole Editor - CFBundleURLName - $(PRODUCT_BUNDLE_IDENTIFIER) CFBundleURLSchemes ShareMedia-$(PRODUCT_BUNDLE_IDENTIFIER) - + BGTaskSchedulerPermittedIdentifiers org.nkn.nmobile.backgroundtask @@ -63,6 +62,24 @@ LSSupportsOpeningDocumentsInPlace + CFBundleDocumentTypes + + + CFBundleTypeName + ShareHandler + LSHandlerRank + Alternate + LSItemContentTypes + + public.file-url + public.image + public.text + public.movie + public.url + public.data + + + NSAppTransportSecurity NSAllowsArbitraryLoads diff --git a/ios/Share Extension/Info.plist b/ios/Share Extension/Info.plist deleted file mode 100644 index 2ef9ea0a9..000000000 --- a/ios/Share Extension/Info.plist +++ /dev/null @@ -1,46 +0,0 @@ - - - - - AppGroupId - $(CUSTOM_GROUP_ID) - CFBundleVersion - $(FLUTTER_BUILD_NUMBER) - NSExtension - - NSExtensionAttributes - - PHSupportedMediaTypes - - - Video - - Image - - NSExtensionActivationRule - - - NSExtensionActivationSupportsText - - - NSExtensionActivationSupportsWebURLWithMaxCount - 1 - - NSExtensionActivationSupportsImageWithMaxCount - 100 - - NSExtensionActivationSupportsMovieWithMaxCount - 100 - - - NSExtensionActivationSupportsFileWithMaxCount - 1 - - - NSExtensionMainStoryboard - MainInterface - NSExtensionPointIdentifier - com.apple.share-services - - - \ No newline at end of file diff --git a/ios/Share Extension/RSIShareViewController.swift b/ios/Share Extension/RSIShareViewController.swift deleted file mode 100644 index e13f2ff93..000000000 --- a/ios/Share Extension/RSIShareViewController.swift +++ /dev/null @@ -1,332 +0,0 @@ -// -// RSIShareViewController.swift -// receive_sharing_intent -// -// Created by Kasem Mohamed on 2024-01-25. -// - -import UIKit -import Social -import MobileCoreServices -import Photos - -@available(swift, introduced: 5.0) -open class RSIShareViewController: SLComposeServiceViewController { - var hostAppBundleIdentifier = "" - var appGroupId = "" - var sharedMedia: [SharedMediaFile] = [] - - /// Override this method to return false if you don't want to redirect to host app automatically - /// Default is true - open func shouldAutoRedirect() -> Bool { - return true - } - - open override func isContentValid() -> Bool { - return true - } - - open override func viewDidLoad() { - super.viewDidLoad() - - // load group and app id from build info - loadIds() - } - - // Redirect to host app when user click on Post - open override func didSelectPost() { - saveAndRedirect(message: contentText) - } - - open override func viewDidAppear(_ animated: Bool) { - super.viewDidAppear(animated) - - // This is called after the user selects Post. Do the upload of contentText and/or NSExtensionContext attachments. - if let content = extensionContext!.inputItems[0] as? NSExtensionItem { - if let contents = content.attachments { - for (index, attachment) in (contents).enumerated() { - for type in SharedMediaType.allCases { - if attachment.hasItemConformingToTypeIdentifier(type.toUTTypeIdentifier) { - attachment.loadItem(forTypeIdentifier: type.toUTTypeIdentifier) { [weak self] data, error in - guard let this = self, error == nil else { - self?.dismissWithError() - return - } - switch type { - case .text: - if let text = data as? String { - this.handleMedia(forLiteral: text, - type: type, - index: index, - content: content) - } - case .url: - if let url = data as? URL { - this.handleMedia(forLiteral: url.absoluteString, - type: type, - index: index, - content: content) - } - default: - if let url = data as? URL { - this.handleMedia(forFile: url, - type: type, - index: index, - content: content) - } - else if let image = data as? UIImage { - this.handleMedia(forUIImage: image, - type: type, - index: index, - content: content) - } - } - } - break - } - } - } - } - } - } - - open override func configurationItems() -> [Any]! { - // To add configuration options via table cells at the bottom of the sheet, return an array of SLComposeSheetConfigurationItem here. - return [] - } - - private func loadIds() { - // loading Share extension App Id - let shareExtensionAppBundleIdentifier = Bundle.main.bundleIdentifier! - - - // extract host app bundle id from ShareExtension id - // by default it's . - // for example: "com.kasem.sharing.Share-Extension" -> com.kasem.sharing - let lastIndexOfPoint = shareExtensionAppBundleIdentifier.lastIndex(of: ".") - hostAppBundleIdentifier = String(shareExtensionAppBundleIdentifier[.. - let customAppGroupId = Bundle.main.object(forInfoDictionaryKey: kAppGroupIdKey) as? String - - appGroupId = customAppGroupId ?? defaultAppGroupId - } - - - private func handleMedia(forLiteral item: String, type: SharedMediaType, index: Int, content: NSExtensionItem) { - sharedMedia.append(SharedMediaFile( - path: item, - mimeType: type == .text ? "text/plain": nil, - type: type - )) - if index == (content.attachments?.count ?? 0) - 1 { - if shouldAutoRedirect() { - saveAndRedirect() - } - } - } - - private func handleMedia(forUIImage image: UIImage, type: SharedMediaType, index: Int, content: NSExtensionItem){ - let tempPath = FileManager.default.containerURL(forSecurityApplicationGroupIdentifier: appGroupId)!.appendingPathComponent("TempImage.png") - if self.writeTempFile(image, to: tempPath) { - let newPathDecoded = tempPath.absoluteString.removingPercentEncoding! - sharedMedia.append(SharedMediaFile( - path: newPathDecoded, - mimeType: type == .image ? "image/png": nil, - type: type - )) - } - if index == (content.attachments?.count ?? 0) - 1 { - if shouldAutoRedirect() { - saveAndRedirect() - } - } - } - - private func handleMedia(forFile url: URL, type: SharedMediaType, index: Int, content: NSExtensionItem) { - let fileName = getFileName(from: url, type: type) - let newPath = FileManager.default.containerURL(forSecurityApplicationGroupIdentifier: appGroupId)!.appendingPathComponent(fileName) - - if copyFile(at: url, to: newPath) { - // The path should be decoded because Flutter is not expecting url encoded file names - let newPathDecoded = newPath.absoluteString.removingPercentEncoding!; - if type == .video { - // Get video thumbnail and duration - if let videoInfo = getVideoInfo(from: url) { - let thumbnailPathDecoded = videoInfo.thumbnail?.removingPercentEncoding; - sharedMedia.append(SharedMediaFile( - path: newPathDecoded, - mimeType: url.mimeType(), - thumbnail: thumbnailPathDecoded, - duration: videoInfo.duration, - type: type - )) - } - } else { - sharedMedia.append(SharedMediaFile( - path: newPathDecoded, - mimeType: url.mimeType(), - type: type - )) - } - } - - if index == (content.attachments?.count ?? 0) - 1 { - if shouldAutoRedirect() { - saveAndRedirect() - } - } - } - - - // Save shared media and redirect to host app - private func saveAndRedirect(message: String? = nil) { - let userDefaults = UserDefaults(suiteName: appGroupId) - userDefaults?.set(toData(data: sharedMedia), forKey: kUserDefaultsKey) - userDefaults?.set(message, forKey: kUserDefaultsMessageKey) - userDefaults?.synchronize() - redirectToHostApp() - } - - private func redirectToHostApp() { - // ids may not loaded yet so we need loadIds here too - loadIds() - let url = URL(string: "\(kSchemePrefix)-\(hostAppBundleIdentifier):share") - var responder = self as UIResponder? - - if #available(iOS 18.0, *) { - while responder != nil { - if let application = responder as? UIApplication { - application.open(url!, options: [:], completionHandler: nil) - } - responder = responder?.next - } - } else { - let selectorOpenURL = sel_registerName("openURL:") - - while (responder != nil) { - if (responder?.responds(to: selectorOpenURL))! { - _ = responder?.perform(selectorOpenURL, with: url) - } - responder = responder!.next - } - } - - extensionContext!.completeRequest(returningItems: [], completionHandler: nil) - } - - private func dismissWithError() { - print("[ERROR] Error loading data!") - let alert = UIAlertController(title: "Error", message: "Error loading data", preferredStyle: .alert) - - let action = UIAlertAction(title: "Error", style: .cancel) { _ in - self.dismiss(animated: true, completion: nil) - } - - alert.addAction(action) - present(alert, animated: true, completion: nil) - extensionContext!.completeRequest(returningItems: [], completionHandler: nil) - } - - private func getFileName(from url: URL, type: SharedMediaType) -> String { - var name = url.lastPathComponent - if name.isEmpty { - switch type { - case .image: - name = UUID().uuidString + ".png" - case .video: - name = UUID().uuidString + ".mp4" - case .text: - name = UUID().uuidString + ".txt" - default: - name = UUID().uuidString - } - } - return name - } - - private func writeTempFile(_ image: UIImage, to dstURL: URL) -> Bool { - do { - if FileManager.default.fileExists(atPath: dstURL.path) { - try FileManager.default.removeItem(at: dstURL) - } - let pngData = image.pngData(); - try pngData?.write(to: dstURL); - return true; - } catch (let error){ - print("Cannot write to temp file: \(error)"); - return false; - } - } - - private func copyFile(at srcURL: URL, to dstURL: URL) -> Bool { - do { - if FileManager.default.fileExists(atPath: dstURL.path) { - try FileManager.default.removeItem(at: dstURL) - } - try FileManager.default.copyItem(at: srcURL, to: dstURL) - } catch (let error) { - print("Cannot copy item at \(srcURL) to \(dstURL): \(error)") - return false - } - return true - } - - private func getVideoInfo(from url: URL) -> (thumbnail: String?, duration: Double)? { - let asset = AVAsset(url: url) - let duration = (CMTimeGetSeconds(asset.duration) * 1000).rounded() - let thumbnailPath = getThumbnailPath(for: url) - - if FileManager.default.fileExists(atPath: thumbnailPath.path) { - return (thumbnail: thumbnailPath.absoluteString, duration: duration) - } - - var saved = false - let assetImgGenerate = AVAssetImageGenerator(asset: asset) - assetImgGenerate.appliesPreferredTrackTransform = true - // let scale = UIScreen.main.scale - assetImgGenerate.maximumSize = CGSize(width: 360, height: 360) - do { - let img = try assetImgGenerate.copyCGImage(at: CMTimeMakeWithSeconds(600, preferredTimescale: 1), actualTime: nil) - try UIImage(cgImage: img).pngData()?.write(to: thumbnailPath) - saved = true - } catch { - saved = false - } - - return saved ? (thumbnail: thumbnailPath.absoluteString, duration: duration): nil - } - - private func getThumbnailPath(for url: URL) -> URL { - let fileName = Data(url.lastPathComponent.utf8).base64EncodedString().replacingOccurrences(of: "==", with: "") - let path = FileManager.default - .containerURL(forSecurityApplicationGroupIdentifier: appGroupId)! - .appendingPathComponent("\(fileName).jpg") - return path - } - - private func toData(data: [SharedMediaFile]) -> Data { - let encodedData = try? JSONEncoder().encode(data) - return encodedData! - } -} - -extension URL { - public func mimeType() -> String { - if #available(iOS 14.0, *) { - if let mimeType = UTType(filenameExtension: self.pathExtension)?.preferredMIMEType { - return mimeType - } - } else { - if let uti = UTTypeCreatePreferredIdentifierForTag(kUTTagClassFilenameExtension, self.pathExtension as NSString, nil)?.takeRetainedValue() { - if let mimetype = UTTypeCopyPreferredTagWithClass(uti, kUTTagClassMIMEType)?.takeRetainedValue() { - return mimetype as String - } - } - } - - return "application/octet-stream" - } -} diff --git a/ios/Share Extension/ShareViewController.swift b/ios/Share Extension/ShareViewController.swift deleted file mode 100644 index 78704fd34..000000000 --- a/ios/Share Extension/ShareViewController.swift +++ /dev/null @@ -1,14 +0,0 @@ -// If you get no such module 'receive_sharing_intent' error. -// Go to Build Phases of your Runner target and -// move `Embed Foundation Extension` to the top of `Thin Binary`. -//import receive_sharing_intent - -class ShareViewController: RSIShareViewController { - - // Use this method to return false if you don't want to redirect to host app automatically. - // Default is true - override func shouldAutoRedirect() -> Bool { - return false - } - -} diff --git a/ios/Share Extension/SwiftReceiveSharingIntentPlugin.swift b/ios/Share Extension/SwiftReceiveSharingIntentPlugin.swift deleted file mode 100644 index 017ff45e5..000000000 --- a/ios/Share Extension/SwiftReceiveSharingIntentPlugin.swift +++ /dev/null @@ -1,74 +0,0 @@ -import UIKit -import Photos - -public let kSchemePrefix = "ShareMedia" -public let kUserDefaultsKey = "ShareKey" -public let kUserDefaultsMessageKey = "ShareMessageKey" -public let kAppGroupIdKey = "AppGroupId" - -public class SharedMediaFile: Codable { - var path: String - var mimeType: String? - var thumbnail: String? // video thumbnail - var duration: Double? // video duration in milliseconds - var message: String? // post message - var type: SharedMediaType - - - public init( - path: String, - mimeType: String? = nil, - thumbnail: String? = nil, - duration: Double? = nil, - message: String?=nil, - type: SharedMediaType) { - self.path = path - self.mimeType = mimeType - self.thumbnail = thumbnail - self.duration = duration - self.message = message - self.type = type - } -} - -public enum SharedMediaType: String, Codable, CaseIterable { - case image - case video - case text -// case audio - case file - case url - - public var toUTTypeIdentifier: String { - if #available(iOS 14.0, *) { - switch self { - case .image: - return UTType.image.identifier - case .video: - return UTType.movie.identifier - case .text: - return UTType.text.identifier - // case .audio: - // return UTType.audio.identifier - case .file: - return UTType.fileURL.identifier - case .url: - return UTType.url.identifier - } - } - switch self { - case .image: - return "public.image" - case .video: - return "public.movie" - case .text: - return "public.text" -// case .audio: -// return "public.audio" - case .file: - return "public.file-url" - case .url: - return "public.url" - } - } -} diff --git a/ios/Share Extension/Base.lproj/MainInterface.storyboard b/ios/ShareExtension/Base.lproj/MainInterface.storyboard similarity index 100% rename from ios/Share Extension/Base.lproj/MainInterface.storyboard rename to ios/ShareExtension/Base.lproj/MainInterface.storyboard diff --git a/ios/ShareExtension/Info.plist b/ios/ShareExtension/Info.plist new file mode 100644 index 000000000..67c8910d3 --- /dev/null +++ b/ios/ShareExtension/Info.plist @@ -0,0 +1,53 @@ + + + + + + + + CFBundleVersion + $(FLUTTER_BUILD_NUMBER) + NSExtension + + NSExtensionAttributes + + + IntentsSupported + + INSendMessageIntent + + + NSExtensionActivationRule + + + + SUBQUERY ( + extensionItems, + $extensionItem, + SUBQUERY ( + $extensionItem.attachments, + $attachment, + ( + ANY $attachment.registeredTypeIdentifiers UTI-CONFORMS-TO "public.file-url" + || ANY $attachment.registeredTypeIdentifiers UTI-CONFORMS-TO "public.image" + || ANY $attachment.registeredTypeIdentifiers UTI-CONFORMS-TO "public.text" + || ANY $attachment.registeredTypeIdentifiers UTI-CONFORMS-TO "public.movie" + || ANY $attachment.registeredTypeIdentifiers UTI-CONFORMS-TO "public.url" + ) + ).@count > 0 + ).@count > 0 + + PHSupportedMediaTypes + + Video + Image + + + NSExtensionMainStoryboard + MainInterface + NSExtensionPointIdentifier + com.apple.share-services + + + \ No newline at end of file diff --git a/ios/Share Extension/Share Extension.entitlements b/ios/ShareExtension/ShareExtension.entitlements similarity index 100% rename from ios/Share Extension/Share Extension.entitlements rename to ios/ShareExtension/ShareExtension.entitlements diff --git a/ios/ShareExtension/ShareViewController.swift b/ios/ShareExtension/ShareViewController.swift new file mode 100644 index 000000000..b17cff89c --- /dev/null +++ b/ios/ShareExtension/ShareViewController.swift @@ -0,0 +1,3 @@ +import share_handler_ios_models + +class ShareViewController: ShareHandlerIosViewController {} diff --git a/lib/app.dart b/lib/app.dart index 05e0e85bb..c28467377 100644 --- a/lib/app.dart +++ b/lib/app.dart @@ -16,11 +16,10 @@ import 'package:nmobile/native/common.dart'; import 'package:nmobile/schema/wallet.dart'; import 'package:nmobile/screens/chat/home.dart'; import 'package:nmobile/screens/settings/home.dart'; -import 'package:nmobile/screens/wallet/home.dart'; import 'package:nmobile/services/task.dart'; import 'package:nmobile/utils/asset.dart'; import 'package:nmobile/utils/logger.dart'; -import 'package:receive_sharing_intent/receive_sharing_intent.dart'; +import 'package:share_handler/share_handler.dart'; class AppScreen extends StatefulWidget { static const String routeName = '/'; @@ -62,6 +61,7 @@ class _AppScreenState extends State with WidgetsBindingObserver { Completer loginCompleter = Completer(); bool isAuthProgress = false; + SharedMedia? media; @override void initState() { @@ -117,25 +117,25 @@ class _AppScreenState extends State with WidgetsBindingObserver { } }); - // For sharing images coming from outside the app while the app is in the memory - _intentDataMediaStreamSubscription = ReceiveSharingIntent.instance.getMediaStream().listen((List? values) async { - if (values == null || values.isEmpty) return; - await loginCompleter.future; - ShareHelper.showWithFiles(this.context, values); - }, onError: (err, stack) { - handleError(err, stack); - }); + initPlatformState(); + // wallet + taskService.addTask(TaskService.KEY_WALLET_BALANCE, 60, (key) => walletCommon.queryAllBalance(), delayMs: 1 * 1000); + } - // For sharing images coming from outside the app while the app is closed - ReceiveSharingIntent.instance.getInitialMedia().then((List? values) async { - if (values == null || values.isEmpty) return; + Future initPlatformState() async { + final handler = ShareHandlerPlatform.instance; + media = await handler.getInitialSharedMedia(); + + handler.sharedMediaStream.listen((SharedMedia media) async { + if (media.attachments?.isNotEmpty != true) return; + if (!mounted) return; + this.media = media; await loginCompleter.future; - ShareHelper.showWithFiles(this.context, values); + ShareHelper.showWithFiles(this.context, media); }); + if (!mounted) return; - - // wallet - taskService.addTask(TaskService.KEY_WALLET_BALANCE, 60, (key) => walletCommon.queryAllBalance(), delayMs: 1 * 1000); + setState(() {}); } @override diff --git a/lib/helpers/share.dart b/lib/helpers/share.dart index c20c9c2c6..6bd2a74a3 100644 --- a/lib/helpers/share.dart +++ b/lib/helpers/share.dart @@ -12,7 +12,8 @@ import 'package:nmobile/schema/topic.dart'; import 'package:nmobile/screens/contact/home.dart'; import 'package:nmobile/utils/logger.dart'; import 'package:nmobile/utils/path.dart'; -import 'package:receive_sharing_intent/receive_sharing_intent.dart'; +import 'package:share_handler/share_handler.dart'; +import 'package:video_player/video_player.dart'; class ShareHelper { static Future showWithTexts(BuildContext? context, List shareTexts) async { @@ -32,8 +33,8 @@ class ShareHelper { } } - static Future showWithFiles(BuildContext? context, List shareMedias) async { - if (shareMedias.isEmpty) return; + static Future showWithFiles(BuildContext? context, SharedMedia shareMedia) async { + // pick target first var target = await ContactHomeScreen.go(context, title: Settings.locale((s) => s.share, ctx: context), selectContact: true, selectGroup: true); if (target == null) return; // subPath @@ -50,16 +51,27 @@ class ShareHelper { } else { return; } - await _sendShareMedias(shareMedias, target, subPath); + // attachments from share_handler (filter out nulls) + final List attachments = + (shareMedia.attachments ?? const []).whereType().toList(); + if (attachments.isEmpty) { + // fallback to text if present + final content = shareMedia.content?.trim(); + if (content != null && content.isNotEmpty) { + await showWithTexts(context, [content]); + } + return; + } + await _sendShareAttachments(attachments, target, subPath); } - static Future _sendShareMedias(List shareMedias, dynamic target, String? subPath) async { + static Future _sendShareAttachments(List attachments, dynamic target, String? subPath) async { if (target == null) return; // medias List> results = []; - for (var i = 0; i < shareMedias.length; i++) { - SharedMediaFile result = shareMedias[i]; - Map? params = await _getParamsFromShareMedia(result, subPath, Settings.sizeIpfsMax); + for (var i = 0; i < attachments.length; i++) { + SharedAttachment attachment = attachments[i]; + Map? params = await _getParamsFromAttachment(attachment, subPath, Settings.sizeIpfsMax); if (params == null || params.isEmpty) continue; results.add(params); } @@ -90,33 +102,35 @@ class ShareHelper { } } - static Future?> _getParamsFromShareMedia(SharedMediaFile shareMedia, String? subPath, int? maxSize) async { - logger.i("ShareHelper - _getParamsFromShareMedia - SharedMediaFile:$shareMedia"); - // path - if (shareMedia.path.isEmpty) { - logger.e("ShareHelper - _getParamsFromShareMedia - path is empty"); + static Future?> _getParamsFromAttachment(SharedAttachment attachment, String? subPath, int? maxSize) async { + logger.i("ShareHelper - _getParamsFromAttachment - SharedAttachment:$attachment"); + // path from share_handler may be file:// URL + String rawPath = attachment.path; + if (rawPath.isEmpty) { + logger.e("ShareHelper - _getParamsFromAttachment - path is empty"); return null; } - File file = File(shareMedia.path); + final String resolvedPath = rawPath.startsWith('file://') ? Uri.parse(rawPath).path : rawPath; + File file = File(resolvedPath); if (!file.existsSync()) { - logger.e("ShareHelper - _getParamsFromShareMedia - file is empty"); + logger.e("ShareHelper - _getParamsFromAttachment - file not exists: $resolvedPath"); return null; } - // type - String mimeType = ""; - if (shareMedia.type == SharedMediaType.image) { + // type -> mime bucket + String mimeType = "file"; + if (attachment.type == SharedAttachmentType.image) { mimeType = "image"; - } else if (shareMedia.type == SharedMediaType.video) { + } else if (attachment.type == SharedAttachmentType.video) { mimeType = "video"; - } else if (shareMedia.type == SharedMediaType.file) { - mimeType = "file"; + } else if (attachment.type == SharedAttachmentType.audio) { + mimeType = "audio"; } // ext String ext = Path.getFileExt(file, ""); if (ext.isEmpty) { - if (shareMedia.type == SharedMediaType.image) { + if (attachment.type == SharedAttachmentType.image) { ext = FileHelper.DEFAULT_IMAGE_EXT; - } else if (shareMedia.type == SharedMediaType.video) { + } else if (attachment.type == SharedAttachmentType.video) { ext = FileHelper.DEFAULT_VIDEO_EXT; } } @@ -141,41 +155,31 @@ class ShareHelper { // thumbnail String? thumbnailPath; int? thumbnailSize; - if ((shareMedia.thumbnail != null) && (shareMedia.thumbnail?.isNotEmpty == true)) { - File thumbnail = File(shareMedia.thumbnail ?? ""); - if (thumbnail.existsSync()) { - thumbnailPath = await Path.getRandomFile(clientCommon.getPublicKey(), DirType.chat, subPath: subPath, fileExt: FileHelper.DEFAULT_IMAGE_EXT); - File saveThumbnail = File(thumbnailPath); - if (!await saveThumbnail.exists()) { - await saveThumbnail.create(recursive: true); - } else { - await saveThumbnail.delete(); - await saveThumbnail.create(recursive: true); - } - saveThumbnail = await thumbnail.copy(thumbnailPath); - thumbnailSize = saveThumbnail.lengthSync(); + double? durationSeconds; + if (mimeType.contains("video") == true) { + thumbnailPath = await Path.getRandomFile(clientCommon.getPublicKey(), DirType.chat, subPath: subPath, fileExt: FileHelper.DEFAULT_IMAGE_EXT); + Map? res = await MediaPicker.getVideoThumbnail(filePath, thumbnailPath); + if (res != null && res.isNotEmpty) { + thumbnailPath = res["path"]; + thumbnailSize = res["size"]; } - } - if (thumbnailPath == null || thumbnailPath.isEmpty) { - if (mimeType.contains("video") == true) { - thumbnailPath = await Path.getRandomFile(clientCommon.getPublicKey(), DirType.chat, subPath: subPath, fileExt: FileHelper.DEFAULT_IMAGE_EXT); - Map? res = await MediaPicker.getVideoThumbnail(filePath, thumbnailPath); - if (res != null && res.isNotEmpty) { - thumbnailPath = res["path"]; - thumbnailSize = res["size"]; - } - } else if ((mimeType.contains("image") == true) && (size > Settings.piecesMaxSize)) { - thumbnailPath = await Path.getRandomFile(clientCommon.getPublicKey(), DirType.chat, subPath: subPath, fileExt: FileHelper.DEFAULT_IMAGE_EXT); - File? thumbnail = await MediaPicker.compressImageBySize(File(filePath), savePath: thumbnailPath, maxSize: Settings.sizeThumbnailMax, bestSize: Settings.sizeThumbnailBest, force: true); - if (thumbnail != null) { - thumbnailPath = thumbnail.absolute.path; - thumbnailSize = thumbnail.lengthSync(); - } + // duration + try { + var controller = VideoPlayerController.file(File(filePath)); + await controller.initialize(); + durationSeconds = controller.value.duration.inMilliseconds / 1000.0; + await controller.dispose(); + } catch (_) {} + } else if ((mimeType.contains("image") == true) && (size > Settings.piecesMaxSize)) { + thumbnailPath = await Path.getRandomFile(clientCommon.getPublicKey(), DirType.chat, subPath: subPath, fileExt: FileHelper.DEFAULT_IMAGE_EXT); + File? thumbnail = await MediaPicker.compressImageBySize(File(filePath), savePath: thumbnailPath, maxSize: Settings.sizeThumbnailMax, bestSize: Settings.sizeThumbnailBest, force: true); + if (thumbnail != null) { + thumbnailPath = thumbnail.absolute.path; + thumbnailSize = thumbnail.lengthSync(); } } // map if (filePath.isNotEmpty) { - int? duration = shareMedia.duration; Map params = { "path": filePath, "size": size, @@ -184,10 +188,10 @@ class ShareHelper { "mimeType": mimeType, "width": null, "height": null, - "duration": (duration != null) ? (duration / 1000) : null, + "duration": durationSeconds, "thumbnailPath": thumbnailPath, "thumbnailSize": thumbnailSize, - "message": shareMedia.message, + "message": null, }; return params; } diff --git a/lib/main.dart b/lib/main.dart index a7550cdc1..4fc5201cc 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -5,6 +5,7 @@ import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_localizations/flutter_localizations.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:nkn_sdk_flutter/client.dart'; import 'package:nkn_sdk_flutter/utils/hex.dart'; import 'package:nkn_sdk_flutter/wallet.dart'; @@ -24,7 +25,6 @@ import 'package:nmobile/storages/settings.dart' as settings_storage; import 'package:nmobile/storages/wallet.dart'; import 'package:nmobile/utils/logger.dart'; import 'package:sentry_flutter/sentry_flutter.dart'; -import 'package:flutter_riverpod/flutter_riverpod.dart'; void main() async { WidgetsFlutterBinding.ensureInitialized(); diff --git a/pubspec.lock b/pubspec.lock index 23eb7cd42..df59d05e7 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -1283,14 +1283,6 @@ packages: url: "https://pub.flutter-io.cn" source: hosted version: "4.1.0" - receive_sharing_intent: - dependency: "direct main" - description: - name: receive_sharing_intent - sha256: ec76056e4d258ad708e76d85591d933678625318e411564dcb9059048ca3a593 - url: "https://pub.flutter-io.cn" - source: hosted - version: "1.8.1" riverpod: dependency: transitive description: @@ -1331,6 +1323,38 @@ packages: url: "https://pub.flutter-io.cn" source: hosted version: "8.14.2" + share_handler: + dependency: "direct main" + description: + name: share_handler + sha256: "0a6d007f0e44fbee27164adcd159ecbc88238864313f4e5c58161cae2180328d" + url: "https://pub.flutter-io.cn" + source: hosted + version: "0.0.25" + share_handler_android: + dependency: transitive + description: + name: share_handler_android + sha256: caf555b933dc72783aa37fef75688c7b86bd6f7bc17d80fbf585bc42f123cc8d + url: "https://pub.flutter-io.cn" + source: hosted + version: "0.0.11" + share_handler_ios: + dependency: transitive + description: + name: share_handler_ios + sha256: cdc21f88f336a944157a8e9ceb191525cee3b082d6eb6c2082488e4f09dc3ece + url: "https://pub.flutter-io.cn" + source: hosted + version: "0.0.15" + share_handler_platform_interface: + dependency: transitive + description: + name: share_handler_platform_interface + sha256: "7a4df95a87b326b2f07458d937f2281874567c364b7b7ebe4e7d50efaae5f106" + url: "https://pub.flutter-io.cn" + source: hosted + version: "0.0.6" share_plus: dependency: "direct main" description: diff --git a/pubspec.yaml b/pubspec.yaml index 215bb2ca5..c5b9b3a08 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -16,7 +16,7 @@ publish_to: 'none' # Remove this line if you wish to publish to pub.dev # Read more about iOS versioning at # https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html -version: 1.7.0+338 +version: 1.7.0+339 environment: sdk: ">=2.12.0 <3.0.0" @@ -35,6 +35,7 @@ dependencies: device_info_plus: ^11.2.0 connectivity_plus: ^6.1.3 share_plus: ^10.1.3 + share_handler: ^0.0.25 path_provider: ^2.0.11 shared_preferences: ^2.5.3 url_launcher: ^6.3.1 @@ -57,7 +58,6 @@ dependencies: flutter_local_notifications: ^18.0.1 fixnum: ^1.0.1 android_id: 0.4.0 - receive_sharing_intent: ^1.8.1 # log logger: ^2.0.2 From 07351c0433b27d15d5750498d1eed2da8f5b339d Mon Sep 17 00:00:00 2001 From: Heron Date: Thu, 25 Sep 2025 11:44:48 +0800 Subject: [PATCH 21/47] Upgrade image_gallery_saver_plus --- ios/Podfile.lock | 10 +- lib/screens/common/media.dart | 24 +- lib/screens/common/photo.dart | 21 +- lib/screens/common/video.dart | 21 +- plugins/image_gallery_saver/.gitignore | 13 - plugins/image_gallery_saver/.metadata | 10 - plugins/image_gallery_saver/.travis.yaml | 19 -- plugins/image_gallery_saver/.travis.yml | 19 -- plugins/image_gallery_saver/CHANGELOG.md | 106 -------- plugins/image_gallery_saver/LICENSE | 21 -- plugins/image_gallery_saver/README.md | 81 ------ .../image_gallery_saver/android/.gitignore | 8 - .../image_gallery_saver/android/build.gradle | 45 ---- .../android/gradle.properties | 3 - .../android/gradle/wrapper/gradle-wrapper.jar | Bin 54329 -> 0 bytes .../gradle/wrapper/gradle-wrapper.properties | 6 - plugins/image_gallery_saver/android/gradlew | 172 ------------ .../image_gallery_saver/android/gradlew.bat | 84 ------ .../android/settings.gradle | 1 - .../android/src/main/AndroidManifest.xml | 3 - .../ImageGallerySaverPlugin.kt | 246 ------------------ plugins/image_gallery_saver/ios/.gitignore | 36 --- .../image_gallery_saver/ios/Assets/.gitkeep | 0 .../ios/Classes/ImageGallerySaverPlugin.h | 4 - .../ios/Classes/ImageGallerySaverPlugin.m | 13 - .../SwiftImageGallerySaverPlugin.swift | 194 -------------- .../ios/image_gallery_saver.podspec | 23 -- .../lib/image_gallery_saver.dart | 38 --- plugins/image_gallery_saver/pubspec.yaml | 31 --- pubspec.lock | 15 +- pubspec.yaml | 7 +- 31 files changed, 59 insertions(+), 1215 deletions(-) delete mode 100644 plugins/image_gallery_saver/.gitignore delete mode 100644 plugins/image_gallery_saver/.metadata delete mode 100644 plugins/image_gallery_saver/.travis.yaml delete mode 100644 plugins/image_gallery_saver/.travis.yml delete mode 100644 plugins/image_gallery_saver/CHANGELOG.md delete mode 100644 plugins/image_gallery_saver/LICENSE delete mode 100644 plugins/image_gallery_saver/README.md delete mode 100644 plugins/image_gallery_saver/android/.gitignore delete mode 100644 plugins/image_gallery_saver/android/build.gradle delete mode 100644 plugins/image_gallery_saver/android/gradle.properties delete mode 100644 plugins/image_gallery_saver/android/gradle/wrapper/gradle-wrapper.jar delete mode 100644 plugins/image_gallery_saver/android/gradle/wrapper/gradle-wrapper.properties delete mode 100644 plugins/image_gallery_saver/android/gradlew delete mode 100644 plugins/image_gallery_saver/android/gradlew.bat delete mode 100644 plugins/image_gallery_saver/android/settings.gradle delete mode 100644 plugins/image_gallery_saver/android/src/main/AndroidManifest.xml delete mode 100644 plugins/image_gallery_saver/android/src/main/kotlin/com/example/imagegallerysaver/ImageGallerySaverPlugin.kt delete mode 100644 plugins/image_gallery_saver/ios/.gitignore delete mode 100644 plugins/image_gallery_saver/ios/Assets/.gitkeep delete mode 100644 plugins/image_gallery_saver/ios/Classes/ImageGallerySaverPlugin.h delete mode 100644 plugins/image_gallery_saver/ios/Classes/ImageGallerySaverPlugin.m delete mode 100644 plugins/image_gallery_saver/ios/Classes/SwiftImageGallerySaverPlugin.swift delete mode 100644 plugins/image_gallery_saver/ios/image_gallery_saver.podspec delete mode 100644 plugins/image_gallery_saver/lib/image_gallery_saver.dart delete mode 100644 plugins/image_gallery_saver/pubspec.yaml diff --git a/ios/Podfile.lock b/ios/Podfile.lock index dd3f6e3e0..70f06ffb7 100644 --- a/ios/Podfile.lock +++ b/ios/Podfile.lock @@ -151,7 +151,7 @@ PODS: - image_cropper (0.0.4): - Flutter - TOCropViewController (~> 2.7.4) - - image_gallery_saver (2.0.2): + - image_gallery_saver_plus (0.0.1): - Flutter - image_picker_ios (0.0.1): - Flutter @@ -261,7 +261,7 @@ DEPENDENCIES: - flutter_secure_storage (from `.symlinks/plugins/flutter_secure_storage/ios`) - flutter_sound (from `.symlinks/plugins/flutter_sound/ios`) - image_cropper (from `.symlinks/plugins/image_cropper/ios`) - - image_gallery_saver (from `.symlinks/plugins/image_gallery_saver/ios`) + - image_gallery_saver_plus (from `.symlinks/plugins/image_gallery_saver_plus/ios`) - image_picker_ios (from `.symlinks/plugins/image_picker_ios/ios`) - local_auth_darwin (from `.symlinks/plugins/local_auth_darwin/darwin`) - nkn_sdk_flutter (from `.symlinks/plugins/nkn_sdk_flutter/ios`) @@ -334,8 +334,8 @@ EXTERNAL SOURCES: :path: ".symlinks/plugins/flutter_sound/ios" image_cropper: :path: ".symlinks/plugins/image_cropper/ios" - image_gallery_saver: - :path: ".symlinks/plugins/image_gallery_saver/ios" + image_gallery_saver_plus: + :path: ".symlinks/plugins/image_gallery_saver_plus/ios" image_picker_ios: :path: ".symlinks/plugins/image_picker_ios/ios" local_auth_darwin: @@ -402,7 +402,7 @@ SPEC CHECKSUMS: GoogleAppMeasurement: 700dce7541804bec33db590a5c496b663fbe2539 GoogleUtilities: 00c88b9a86066ef77f0da2fab05f65d7768ed8e1 image_cropper: 5f162dcf988100dc1513f9c6b7eb42cd6fbf9156 - image_gallery_saver: 14711d79da40581063e8842a11acf1969d781ed7 + image_gallery_saver_plus: e597bf65a7846979417a3eae0763b71b6dfec6c3 image_picker_ios: afb77645f1e1060a27edb6793996ff9b42256909 libwebp: 02b23773aedb6ff1fd38cec7a77b81414c6842a8 local_auth_darwin: 553ce4f9b16d3fdfeafce9cf042e7c9f77c1c391 diff --git a/lib/screens/common/media.dart b/lib/screens/common/media.dart index 38f6e1dd5..327c63d51 100644 --- a/lib/screens/common/media.dart +++ b/lib/screens/common/media.dart @@ -2,11 +2,12 @@ import 'dart:async'; import 'dart:io'; import 'dart:typed_data'; +import 'package:device_info_plus/device_info_plus.dart'; import 'package:dismissible_page/dismissible_page.dart'; import 'package:flutter/cupertino.dart'; import 'package:flutter/material.dart'; import 'package:flutter_spinkit/flutter_spinkit.dart'; -import 'package:image_gallery_saver/image_gallery_saver.dart'; +import 'package:image_gallery_saver_plus/image_gallery_saver_plus.dart'; import 'package:nmobile/common/settings.dart'; import 'package:nmobile/components/base/stateful.dart'; import 'package:nmobile/components/button/button.dart'; @@ -284,12 +285,17 @@ class _MediaScreenState extends BaseStateFulWidgetState with Single } Future _save(int index) async { - // permission - if ((await Permission.mediaLibrary.request()) != PermissionStatus.granted) { - return null; - } - if ((await Permission.storage.request()) != PermissionStatus.granted) { - return null; + if (Platform.isIOS) { + if ((await Permission.photos.request()) != PermissionStatus.granted) { + return null; + } + } else if (Platform.isAndroid) { + final androidInfo = await DeviceInfoPlugin().androidInfo; + if (androidInfo.version.sdkInt <= 32) { + if ((await Permission.storage.request()) != PermissionStatus.granted) { + return null; + } + } } // data if ((index < 0) || (index >= _medias.length)) return null; @@ -307,7 +313,7 @@ class _MediaScreenState extends BaseStateFulWidgetState with Single Uint8List bytes = await file.readAsBytes(); String ext = Path.getFileExt(file, FileHelper.DEFAULT_IMAGE_EXT); String mediaName = 'nkn_' + DateTime.now().millisecondsSinceEpoch.toString() + "." + ext; - Map? result = await ImageGallerySaver.saveImage(bytes, quality: 100, name: mediaName, isReturnImagePathOfIOS: true); + Map? result = await ImageGallerySaverPlus.saveImage(bytes, quality: 100, name: mediaName, isReturnImagePathOfIOS: true); logger.i("MediaScreen - save copy image - path:${result?["filePath"]}"); Toast.show(Settings.locale((s) => (result?["isSuccess"] ?? false) ? s.success : s.failure, ctx: context)); } @@ -318,7 +324,7 @@ class _MediaScreenState extends BaseStateFulWidgetState with Single logger.i("MediaScreen - save video file - path:${file.path}"); String ext = Path.getFileExt(file, FileHelper.DEFAULT_VIDEO_EXT); String mediaName = 'nkn_' + DateTime.now().millisecondsSinceEpoch.toString() + "." + ext; - Map? result = await ImageGallerySaver.saveFile(file.absolute.path, name: mediaName, isReturnPathOfIOS: true); + Map? result = await ImageGallerySaverPlus.saveFile(file.absolute.path, name: mediaName, isReturnPathOfIOS: true); logger.i("MediaScreen - save copy video - path:${result?["filePath"]}"); Toast.show(Settings.locale((s) => (result?["isSuccess"] ?? false) ? s.success : s.failure, ctx: context)); } diff --git a/lib/screens/common/photo.dart b/lib/screens/common/photo.dart index 9a31d645b..0f2b787e5 100644 --- a/lib/screens/common/photo.dart +++ b/lib/screens/common/photo.dart @@ -1,9 +1,10 @@ import 'dart:io'; import 'dart:typed_data'; +import 'package:device_info_plus/device_info_plus.dart'; import 'package:flutter/cupertino.dart'; import 'package:flutter/material.dart'; -import 'package:image_gallery_saver/image_gallery_saver.dart'; +import 'package:image_gallery_saver_plus/image_gallery_saver_plus.dart'; import 'package:nmobile/common/settings.dart'; import 'package:nmobile/components/base/stateful.dart'; import 'package:nmobile/components/button/button.dart'; @@ -60,11 +61,17 @@ class _PhotoScreenState extends BaseStateFulWidgetState with Single } Future _save() async { - if ((await Permission.mediaLibrary.request()) != PermissionStatus.granted) { - return null; - } - if ((await Permission.storage.request()) != PermissionStatus.granted) { - return null; + if (Platform.isIOS) { + if ((await Permission.photos.request()) != PermissionStatus.granted) { + return null; + } + } else if (Platform.isAndroid) { + final androidInfo = await DeviceInfoPlugin().androidInfo; + if (androidInfo.version.sdkInt <= 32) { + if ((await Permission.storage.request()) != PermissionStatus.granted) { + return null; + } + } } File? file = (_contentType == TYPE_FILE) ? File(_content ?? "") : null; @@ -74,7 +81,7 @@ class _PhotoScreenState extends BaseStateFulWidgetState with Single String imageName = 'nkn_' + DateTime.now().millisecondsSinceEpoch.toString() + "." + ext; Uint8List bytes = await file.readAsBytes(); - Map? result = await ImageGallerySaver.saveImage(bytes, quality: 100, name: imageName, isReturnImagePathOfIOS: true); + Map? result = await ImageGallerySaverPlus.saveImage(bytes, quality: 100, name: imageName, isReturnImagePathOfIOS: true); logger.i("PhotoScreen - save copy file - path:${result?["filePath"]}"); Toast.show(Settings.locale((s) => (result?["isSuccess"] ?? false) ? s.success : s.failure, ctx: context)); diff --git a/lib/screens/common/video.dart b/lib/screens/common/video.dart index a031d462b..89cbe710b 100644 --- a/lib/screens/common/video.dart +++ b/lib/screens/common/video.dart @@ -1,8 +1,9 @@ import 'dart:io'; +import 'package:device_info_plus/device_info_plus.dart'; import 'package:flutter/cupertino.dart'; import 'package:flutter/material.dart'; -import 'package:image_gallery_saver/image_gallery_saver.dart'; +import 'package:image_gallery_saver_plus/image_gallery_saver_plus.dart'; import 'package:nmobile/common/settings.dart'; import 'package:nmobile/components/base/stateful.dart'; import 'package:nmobile/components/button/button.dart'; @@ -105,11 +106,17 @@ class _VideoScreenState extends BaseStateFulWidgetState with Single } Future _save() async { - if ((await Permission.mediaLibrary.request()) != PermissionStatus.granted) { - return null; - } - if ((await Permission.storage.request()) != PermissionStatus.granted) { - return null; + if (Platform.isIOS) { + if ((await Permission.photos.request()) != PermissionStatus.granted) { + return null; + } + } else if (Platform.isAndroid) { + final androidInfo = await DeviceInfoPlugin().androidInfo; + if (androidInfo.version.sdkInt <= 32) { + if ((await Permission.storage.request()) != PermissionStatus.granted) { + return null; + } + } } File? file = (_contentType == TYPE_FILE) ? File(_content ?? "") : null; @@ -118,7 +125,7 @@ class _VideoScreenState extends BaseStateFulWidgetState with Single if (file == null || !await file.exists() || _content == null || (_content?.isEmpty == true)) return; String videoName = 'nkn_' + DateTime.now().millisecondsSinceEpoch.toString() + "." + ext; - Map? result = await ImageGallerySaver.saveFile(file.absolute.path, name: videoName, isReturnPathOfIOS: true); + Map? result = await ImageGallerySaverPlus.saveFile(file.absolute.path, name: videoName, isReturnPathOfIOS: true); logger.i("VideoScreen - save copy file - path:${result?["filePath"]}"); Toast.show(Settings.locale((s) => (result?["isSuccess"] ?? false) ? s.success : s.failure, ctx: context)); diff --git a/plugins/image_gallery_saver/.gitignore b/plugins/image_gallery_saver/.gitignore deleted file mode 100644 index b365c0809..000000000 --- a/plugins/image_gallery_saver/.gitignore +++ /dev/null @@ -1,13 +0,0 @@ -.DS_Store -.dart_tool/ - -.packages -.pub/ -.idea/ -.fvm/ - -pubspec.lock - -build/ - -*.iml \ No newline at end of file diff --git a/plugins/image_gallery_saver/.metadata b/plugins/image_gallery_saver/.metadata deleted file mode 100644 index 51497f398..000000000 --- a/plugins/image_gallery_saver/.metadata +++ /dev/null @@ -1,10 +0,0 @@ -# This file tracks properties of this Flutter project. -# Used by Flutter tool to assess capabilities and perform upgrades etc. -# -# This file should be version controlled and should not be manually edited. - -version: - revision: 6a3ff018b199a7febbe2b5adbb564081d8f49e2f - channel: dev - -project_type: plugin diff --git a/plugins/image_gallery_saver/.travis.yaml b/plugins/image_gallery_saver/.travis.yaml deleted file mode 100644 index fc0d22e84..000000000 --- a/plugins/image_gallery_saver/.travis.yaml +++ /dev/null @@ -1,19 +0,0 @@ -os: - - linux -sudo: false -addons: - apt: - # Flutter depends on /usr/lib/x86_64-linux-gnu/libstdc++.so.6 version GLIBCXX_3.4.18 - sources: - - ubuntu-toolchain-r-test # if we don't specify this, the libstdc++6 we get is the wrong version - packages: - - libstdc++6 - - fonts-droid-fallback -before_script: - - git clone https://github.com/flutter/flutter.git -b stable --depth 1 - - ./flutter/bin/flutter doctor -script: - - ./flutter/bin/flutter test -cache: - directories: - - $HOME/.pub-cache diff --git a/plugins/image_gallery_saver/.travis.yml b/plugins/image_gallery_saver/.travis.yml deleted file mode 100644 index fc0d22e84..000000000 --- a/plugins/image_gallery_saver/.travis.yml +++ /dev/null @@ -1,19 +0,0 @@ -os: - - linux -sudo: false -addons: - apt: - # Flutter depends on /usr/lib/x86_64-linux-gnu/libstdc++.so.6 version GLIBCXX_3.4.18 - sources: - - ubuntu-toolchain-r-test # if we don't specify this, the libstdc++6 we get is the wrong version - packages: - - libstdc++6 - - fonts-droid-fallback -before_script: - - git clone https://github.com/flutter/flutter.git -b stable --depth 1 - - ./flutter/bin/flutter doctor -script: - - ./flutter/bin/flutter test -cache: - directories: - - $HOME/.pub-cache diff --git a/plugins/image_gallery_saver/CHANGELOG.md b/plugins/image_gallery_saver/CHANGELOG.md deleted file mode 100644 index aa84ee06a..000000000 --- a/plugins/image_gallery_saver/CHANGELOG.md +++ /dev/null @@ -1,106 +0,0 @@ -## 2.0.3 -- 1.Upgrade flutter version to 3.10.5 -- 2.Android build tools are upgraded to 7.3.0 -- 3.Optimize the Android plugin library code - -## 2.0.2 -- 1.Optimization android plugin - -## 2.0.1 -- 1.Upgrade flutter version to 3.10.2 -- 2.Upgrade Android/ios plug-in related -- 3.Support Android 13 -- 4.Support ios16 - -## 1.7.1 -- optimization - -## 1.7.0 -- optimization - -## 1.6.9 -- optimization - -## 1.6.8 -- Support android 11 save - -## 1.6.7 -- fix ios bug - -## 1.6.6 -* fix ios bug - -## 1.6.5 -* fix android bug - -## 1.6.4 -* formatted code - -## 1.6.3 -* Save result return more message - -## 1.6.2 -* fix crash on iOS when granting permission - -## 1.6.1 -* fix iOS Swift5.1 error - -## 1.6.0 -* Support iOS return save path - -## 1.5.0 -* Save image with JPG and Support special quality(ios & Android) -* Support special Image name for Android -* Upgrade libraries and dependence -* fix docs -* Add more example - -## 1.3.0 - -* Define clang module for static ios builds -* Cleanup example project - -## 1.2.2 - -* Migrate to AndroidX -* optimize git ignore - -## 1.2.1 - -* Support return path for Android. -* Fix bug(save video fail for Android). - -## 1.2.0 - -* Support video save and file path to gallery -* Add example for save video and net image - -## 1.1.0 - -* Upgrade kotlin(1.3.20) and gradle build plugin version(3.3.0). - -## 1.0.0 - -* Updated Kotlin Gradle plugin version - -## 0.1.2 - -* Remove hard coded path - image_gallery_saver in Android - -## 0.1.1 - -* Updated README and Description - -## 0.1.0 - -* Updated README and CHANGELOG -* Add LICENSE -* Add Test - -## 0.0.2 - -* Updated README and CHANGELOG - -## 0.0.1 - -* Initial Open Source release. diff --git a/plugins/image_gallery_saver/LICENSE b/plugins/image_gallery_saver/LICENSE deleted file mode 100644 index c72f3cdd1..000000000 --- a/plugins/image_gallery_saver/LICENSE +++ /dev/null @@ -1,21 +0,0 @@ -MIT License - -Copyright (c) [2023] [zaihui] - -Permission is hereby granted, free of charge, to any person obtaining a copy -of this software and associated documentation files (the "Software"), to deal -in the Software without restriction, including without limitation the rights -to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -copies of the Software, and to permit persons to whom the Software is -furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in all -copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -SOFTWARE. diff --git a/plugins/image_gallery_saver/README.md b/plugins/image_gallery_saver/README.md deleted file mode 100644 index 49bd2a788..000000000 --- a/plugins/image_gallery_saver/README.md +++ /dev/null @@ -1,81 +0,0 @@ -# image_gallery_saver - -[![Build Status](https://travis-ci.org/hui-z/image_gallery_saver.svg?branch=master)](https://travis-ci.org/hui-z/image_gallery_saver#) -[![pub package](https://img.shields.io/pub/v/image_gallery_saver.svg)](https://pub.dartlang.org/packages/image_gallery_saver) -[![license](https://img.shields.io/github/license/mashape/apistatus.svg)](https://choosealicense.com/licenses/mit/) - -We use the `image_picker` plugin to select images from the Android and iOS image library, but it can't save images to the gallery. This plugin can provide this feature. - -## Usage - -To use this plugin, add `image_gallery_saver` as a dependency in your pubspec.yaml file. For example: -```yaml -dependencies: - image_gallery_saver: '^2.0.3' -``` - -## iOS -Your project need create with swift. -Add the following keys to your Info.plist file, located in /ios/Runner/Info.plist: - * NSPhotoLibraryAddUsageDescription - describe why your app needs permission for the photo library. This is called Privacy - Photo Library Additions Usage Description in the visual editor - - ## Android - You need to ask for storage permission to save an image to the gallery. You can handle the storage permission using [flutter_permission_handler](https://github.com/BaseflowIT/flutter-permission-handler). - In Android version 10, Open the manifest file and add this line to your application tag - ``` - - ``` - -## Example -Saving an image from the internet, quality and name is option -``` dart - _saveLocalImage() async { - RenderRepaintBoundary boundary = - _globalKey.currentContext!.findRenderObject() as RenderRepaintBoundary; - ui.Image image = await boundary.toImage(); - ByteData? byteData = - await (image.toByteData(format: ui.ImageByteFormat.png)); - if (byteData != null) { - final result = - await ImageGallerySaver.saveImage(byteData.buffer.asUint8List()); - print(result); - } - } - - _saveNetworkImage() async { - var response = await Dio().get( - "https://ss0.baidu.com/94o3dSag_xI4khGko9WTAnF6hhy/image/h%3D300/sign=a62e824376d98d1069d40a31113eb807/838ba61ea8d3fd1fc9c7b6853a4e251f94ca5f46.jpg", - options: Options(responseType: ResponseType.bytes)); - final result = await ImageGallerySaver.saveImage( - Uint8List.fromList(response.data), - quality: 60, - name: "hello"); - print(result); - } -``` - -Saving file(ig: video/gif/others) from the internet -``` dart - _saveNetworkGifFile() async { - var appDocDir = await getTemporaryDirectory(); - String savePath = appDocDir.path + "/temp.gif"; - String fileUrl = - "https://hyjdoc.oss-cn-beijing.aliyuncs.com/hyj-doc-flutter-demo-run.gif"; - await Dio().download(fileUrl, savePath); - final result = - await ImageGallerySaver.saveFile(savePath, isReturnPathOfIOS: true); - print(result); - } - - _saveNetworkVideoFile() async { - var appDocDir = await getTemporaryDirectory(); - String savePath = appDocDir.path + "/temp.mp4"; - String fileUrl = - "https://s3.cn-north-1.amazonaws.com.cn/mtab.kezaihui.com/video/ForBiggerBlazes.mp4"; - await Dio().download(fileUrl, savePath, onReceiveProgress: (count, total) { - print((count / total * 100).toStringAsFixed(0) + "%"); - }); - final result = await ImageGallerySaver.saveFile(savePath); - print(result); - } -``` diff --git a/plugins/image_gallery_saver/android/.gitignore b/plugins/image_gallery_saver/android/.gitignore deleted file mode 100644 index c6cbe562a..000000000 --- a/plugins/image_gallery_saver/android/.gitignore +++ /dev/null @@ -1,8 +0,0 @@ -*.iml -.gradle -/local.properties -/.idea/workspace.xml -/.idea/libraries -.DS_Store -/build -/captures diff --git a/plugins/image_gallery_saver/android/build.gradle b/plugins/image_gallery_saver/android/build.gradle deleted file mode 100644 index 7ec7a091f..000000000 --- a/plugins/image_gallery_saver/android/build.gradle +++ /dev/null @@ -1,45 +0,0 @@ -group 'com.example.imagegallerysaver' -version '1.0-SNAPSHOT' - -buildscript { - ext.kotlin_version = '1.7.10' - repositories { - google() - mavenCentral() - } - - dependencies { - classpath 'com.android.tools.build:gradle:7.3.0' - classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version" - } -} - -rootProject.allprojects { - repositories { - google() - mavenCentral() - } -} - -apply plugin: 'com.android.library' -apply plugin: 'kotlin-android' - -android { - namespace 'com.example.imagegallerysaver' - compileSdkVersion 30 - - sourceSets { - main.java.srcDirs += 'src/main/kotlin' - } - defaultConfig { - minSdkVersion 16 - testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" - } - lintOptions { - disable 'InvalidPackage' - } -} - -dependencies { - implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk7:$kotlin_version" -} diff --git a/plugins/image_gallery_saver/android/gradle.properties b/plugins/image_gallery_saver/android/gradle.properties deleted file mode 100644 index 53ae0ae47..000000000 --- a/plugins/image_gallery_saver/android/gradle.properties +++ /dev/null @@ -1,3 +0,0 @@ -android.enableJetifier=true -android.useAndroidX=true -org.gradle.jvmargs=-Xmx1536M diff --git a/plugins/image_gallery_saver/android/gradle/wrapper/gradle-wrapper.jar b/plugins/image_gallery_saver/android/gradle/wrapper/gradle-wrapper.jar deleted file mode 100644 index f6b961fd5a86aa5fbfe90f707c3138408be7c718..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 54329 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

<5G2M1WZP5 zzqSuO91lJod(SBDDw<*sX(+F6Uq~YAeYV#2A;XQu_p=N5X+#cmu19Qk>QAnV=k!?wbk5I;tDWgFc}0NkvC*G=V+Yh1cyeJVq~9czZiDXe+S=VfL2g`LWo8om z$Y~FQc6MFjV-t1Y`^D9XMwY*U_re2R?&(O~68T&D4S{X`6JYU-pz=}ew-)V0AOUT1 zVOkHAB-8uBcRjLvz<9HS#a@X*Kc@|W)nyiSgi|u5$Md|P()%2(?olGg@ypoJwp6>m z*dnfjjWC>?_1p;%1brqZyDRR;8EntVA92EJ3ByOxj6a+bhPl z;a?m4rQAV1@QU^#M1HX)0+}A<7TCO`ZR_RzF}X9-M>cRLyN4C+lCk2)kT^3gN^`IT zNP~fAm(wyIoR+l^lQDA(e1Yv}&$I!n?&*p6?lZcQ+vGLLd~fM)qt}wsbf3r=tmVYe zl)ntf#E!P7wlakP9MXS7m0nsAmqxZ*)#j;M&0De`oNmFgi$ov#!`6^4)iQyxg5Iuj zjLAhzQ)r`^hf7`*1`Rh`X;LVBtDSz@0T?kkT1o!ijeyTGt5vc^Cd*tmNgiNo^EaWvaC8$e+nb_{W01j3%=1Y&92YacjCi>eNbwk%-gPQ@H-+4xskQ}f_c=jg^S-# zYFBDf)2?@5cy@^@FHK5$YdAK9cI;!?Jgd}25lOW%xbCJ>By3=HiK@1EM+I46A)Lsd zeT|ZH;KlCml=@;5+hfYf>QNOr^XNH%J-lvev)$Omy8MZ`!{`j>(J5cG&ZXXgv)TaF zg;cz99i$4CX_@3MIb?GL0s*8J=3`#P(jXF(_(6DXZjc@(@h&=M&JG)9&Te1?(^XMW zjjC_70|b=9hB6pKQi`S^Ls7JyJw^@P>Ko^&q8F&?>6i;#CbxUiLz1ZH4lNyd@QACd zu>{!sqjB!2Dg}pbAXD>d!3jW}=5aN0b;rw*W>*PAxm7D)aw(c*RX2@bTGEI|RRp}vw7;NR2wa;rXN{L{Q#=Fa z$x@ms6pqb>!8AuV(prv>|aU8oWV={C&$c zMa=p=CDNOC2tISZcd8~18GN5oTbKY+Vrq;3_obJlfSKRMk;Hdp1`y`&LNSOqeauR_ z^j*Ojl3Ohzb5-a49A8s|UnM*NM8tg}BJXdci5%h&;$afbmRpN0&~9rCnBA`#lG!p zc{(9Y?A0Y9yo?wSYn>iigf~KP$0*@bGZ>*YM4&D;@{<%Gg5^uUJGRrV4 z(aZOGB&{_0f*O=Oi0k{@8vN^BU>s3jJRS&CJOl3o|BE{FAA&a#2YYiX3pZz@|Go-F z|Fly;7eX2OTs>R}<`4RwpHFs9nwh)B28*o5qK1Ge=_^w0m`uJOv!=&!tzt#Save(C zgKU=Bsgql|`ui(e1KVxR`?>Dx>(rD1$iWp&m`v)3A!j5(6vBm*z|aKm*T*)mo(W;R zNGo2`KM!^SS7+*9YxTm6YMm_oSrLceqN*nDOAtagULuZl5Q<7mOnB@Hq&P|#9y{5B z!2x+2s<%Cv2Aa0+u{bjZXS);#IFPk(Ph-K7K?3i|4ro> zRbqJoiOEYo(Im^((r}U4b8nvo_>4<`)ut`24?ILnglT;Pd&U}$lV3U$F9#PD(O=yV zgNNA=GW|(E=&m_1;uaNmipQe?pon4{T=zK!N!2_CJL0E*R^XXIKf*wi!>@l}3_P9Z zF~JyMbW!+n-+>!u=A1ESxzkJy$DRuG+$oioG7(@Et|xVbJ#BCt;J43Nvj@MKvTxzy zMmjNuc#LXBxFAwIGZJk~^!q$*`FME}yKE8d1f5Mp}KHNq(@=Z8YxV}0@;YS~|SpGg$_jG7>_8WWYcVx#4SxpzlV9N4aO>K{c z$P?a_fyDzGX$Of3@ykvedGd<@-R;M^Shlj*SswJLD+j@hi_&_>6WZ}#AYLR0iWMK|A zH_NBeu(tMyG=6VO-=Pb>-Q#$F*or}KmEGg*-n?vWQREURdB#+6AvOj*I%!R-4E_2$ zU5n9m>RWs|Wr;h2DaO&mFBdDb-Z{APGQx$(L`if?C|njd*fC=rTS%{o69U|meRvu?N;Z|Y zbT|ojL>j;q*?xXmnHH#3R4O-59NV1j=uapkK7}6@Wo*^Nd#(;$iuGsb;H315xh3pl zHaJ>h-_$hdNl{+|Zb%DZH%ES;*P*v0#}g|vrKm9;j-9e1M4qX@zkl&5OiwnCz=tb6 zz<6HXD+rGIVpGtkb{Q^LIgExOm zz?I|oO9)!BOLW#krLmWvX5(k!h{i>ots*EhpvAE;06K|u_c~y{#b|UxQ*O@Ks=bca z^_F0a@61j3I(Ziv{xLb8AXQj3;R{f_l6a#H5ukg5rxwF9A$?Qp-Mo54`N-SKc}fWp z0T)-L@V$$&my;l#Ha{O@!fK4-FSA)L&3<${Hcwa7ue`=f&YsXY(NgeDU#sRlT3+9J z6;(^(sjSK@3?oMo$%L-nqy*E;3pb0nZLx6 z;h5)T$y8GXK1DS-F@bGun8|J(v-9o=42&nLJy#}M5D0T^5VWBNn$RpC zZzG6Bt66VY4_?W=PX$DMpKAI!d`INr) zkMB{XPQ<52rvWVQqgI0OL_NWxoe`xxw&X8yVftdODPj5|t}S6*VMqN$-h9)1MBe0N zYq?g0+e8fJCoAksr0af1)FYtz?Me!Cxn`gUx&|T;)695GG6HF7!Kg1zzRf_{VWv^bo81v4$?F6u2g|wxHc6eJQAg&V z#%0DnWm2Rmu71rPJ8#xFUNFC*V{+N_qqFH@gYRLZ6C?GAcVRi>^n3zQxORPG)$-B~ z%_oB?-%Zf7d*Fe;cf%tQwcGv2S?rD$Z&>QC2X^vwYjnr5pa5u#38cHCt4G3|efuci z@3z=#A13`+ztmp;%zjXwPY_aq-;isu*hecWWX_=Z8paSqq7;XYnUjK*T>c4~PR4W7 z#C*%_H&tfGx`Y$w7`dXvVhmovDnT>btmy~SLf>>~84jkoQ%cv=MMb+a{JV&t0+1`I z32g_Y@yDhKe|K^PevP~MiiVl{Ou7^Mt9{lOnXEQ`xY^6L8D$705GON{!1?1&YJEl#fTf5Z)da=yiEQ zGgtC-soFGOEBEB~ZF_{7b(76En>d}mI~XIwNw{e>=Fv)sgcw@qOsykWr?+qAOZSVrQfg}TNI ztKNG)1SRrAt6#Q?(me%)>&A_^DM`pL>J{2xu>xa$3d@90xR61TQDl@fu%_85DuUUA za9tn64?At;{`BAW6oykwntxHeDpXsV#{tmt5RqdN7LtcF4vR~_kZNT|wqyR#z^Xcd zFdymVRZvyLfTpBT>w9<)Ozv@;Yk@dOSVWbbtm^y@@C>?flP^EgQPAwsy75bveo=}T zFxl(f)s)j(0#N_>Or(xEuV(n$M+`#;Pc$1@OjXEJZumkaekVqgP_i}p`oTx;terTx zZpT+0dpUya2hqlf`SpXN{}>PfhajNk_J0`H|2<5E;U5Vh4F8er z;RxLSFgpGhkU>W?IwdW~NZTyOBrQ84H7_?gviIf71l`EETodG9a1!8e{jW?DpwjL? zGEM&eCzwoZt^P*8KHZ$B<%{I}>46IT%jJ3AnnB5P%D2E2Z_ z1M!vr#8r}1|KTqWA4%67ZdbMW2YJ81b(KF&SQ2L1Qn(y-=J${p?xLMx3W7*MK;LFQ z6Z`aU;;mTL4XrrE;HY*Rkh6N%?qviUGNAKiCB~!P}Z->IpO6E(gGd7I#eDuT7j|?nZ zK}I(EJ>$Kb&@338M~O+em9(L!+=0zBR;JAQesx|3?Ok90)D1aS9P?yTh6Poh8Cr4X zk3zc=f2rE7jj+aP7nUsr@~?^EGP>Q>h#NHS?F{Cn`g-gD<8F&dqOh-0sa%pfL`b+1 zUsF*4a~)KGb4te&K0}bE>z3yb8% zibb5Q%Sfiv7feb1r0tfmiMv z@^4XYwg@KZI=;`wC)`1jUA9Kv{HKe2t$WmRcR4y8)VAFjRi zaz&O7Y2tDmc5+SX(bj6yGHYk$dBkWc96u3u&F)2yEE~*i0F%t9Kg^L6MJSb&?wrXi zGSc;_rln$!^ybwYBeacEFRsVGq-&4uC{F)*Y;<0y7~USXswMo>j4?~5%Zm!m@i@-> zXzi82sa-vpU{6MFRktJy+E0j#w`f`>Lbog{zP|9~hg(r{RCa!uGe>Yl536cn$;ouH za#@8XMvS-kddc1`!1LVq;h57~zV`7IYR}pp3u!JtE6Q67 zq3H9ZUcWPm2V4IukS}MCHSdF0qg2@~ufNx9+VMjQP&exiG_u9TZAeAEj*jw($G)zL zq9%#v{wVyOAC4A~AF=dPX|M}MZV)s(qI9@aIK?Pe+~ch|>QYb+78lDF*Nxz2-vpRbtQ*F4$0fDbvNM#CCatgQ@z1+EZWrt z2dZfywXkiW=no5jus-92>gXn5rFQ-COvKyegmL=4+NPzw6o@a?wGE-1Bt;pCHe;34K%Z z-FnOb%!nH;)gX+!a3nCk?5(f1HaWZBMmmC@lc({dUah+E;NOros{?ui1zPC-Q0);w zEbJmdE$oU$AVGQPdm{?xxI_0CKNG$LbY*i?YRQ$(&;NiA#h@DCxC(U@AJ$Yt}}^xt-EC_ z4!;QlLkjvSOhdx!bR~W|Ezmuf6A#@T`2tsjkr>TvW*lFCMY>Na_v8+{Y|=MCu1P8y z89vPiH5+CKcG-5lzk0oY>~aJC_0+4rS@c@ZVKLAp`G-sJB$$)^4*A!B zmcf}lIw|VxV9NSoJ8Ag3CwN&d7`|@>&B|l9G8tXT^BDHOUPrtC70NgwN4${$k~d_4 zJ@eo6%YQnOgq$th?0{h`KnqYa$Nz@vlHw<%!C5du6<*j1nwquk=uY}B8r7f|lY+v7 zm|JU$US08ugor8E$h3wH$c&i~;guC|3-tqJy#T;v(g( zBZtPMSyv%jzf->435yM(-UfyHq_D=6;ouL4!ZoD+xI5uCM5ay2m)RPmm$I}h>()hS zO!0gzMxc`BPkUZ)WXaXam%1;)gedA7SM8~8yIy@6TPg!hR0=T>4$Zxd)j&P-pXeSF z9W`lg6@~YDhd19B9ETv(%er^Xp8Yj@AuFVR_8t*KS;6VHkEDKI#!@l!l3v6`W1`1~ zP{C@keuV4Q`Rjc08lx?zmT$e$!3esc9&$XZf4nRL(Z*@keUbk!GZi(2Bmyq*saOD? z3Q$V<*P-X1p2}aQmuMw9nSMbOzuASsxten7DKd6A@ftZ=NhJ(0IM|Jr<91uAul4JR zADqY^AOVT3a(NIxg|U;fyc#ZnSzw2cr}#a5lZ38>nP{05D)7~ad7JPhw!LqOwATXtRhK!w0X4HgS1i<%AxbFmGJx9?sEURV+S{k~g zGYF$IWSlQonq6}e;B(X(sIH|;52+(LYW}v_gBcp|x%rEAVB`5LXg_d5{Q5tMDu0_2 z|LOm$@K2?lrLNF=mr%YP|U-t)~9bqd+wHb4KuPmNK<}PK6e@aosGZK57=Zt+kcszVOSbe;`E^dN! ze7`ha3WUUU7(nS0{?@!}{0+-VO4A{7+nL~UOPW9_P(6^GL0h${SLtqG!} zKl~Ng5#@Sy?65wk9z*3SA`Dpd4b4T^@C8Fhd8O)k_4%0RZL5?#b~jmgU+0|DB%0Z) zql-cPC>A9HPjdOTpPC` zQwvF}uB5kG$Xr4XnaH#ruSjM*xG?_hT7y3G+8Ox`flzU^QIgb_>2&-f+XB6MDr-na zSi#S+c!ToK84<&m6sCiGTd^8pNdXo+$3^l3FL_E`0 z>8it5YIDxtTp2Tm(?}FX^w{fbfgh7>^8mtvN>9fWgFN_*a1P`Gz*dyOZF{OV7BC#j zQV=FQM5m>47xXgapI$WbPM5V`V<7J9tD)oz@d~MDoM`R^Y6-Na(lO~uvZlpu?;zw6 zVO1faor3dg#JEb5Q*gz4<W8tgC3nE2BG2jeIQs1)<{In&7hJ39x=;ih;CJDy)>0S1at*7n?Wr0ahYCpFjZ|@u91Zl7( zv;CSBRC65-6f+*JPf4p1UZ)k=XivKTX6_bWT~7V#rq0Xjas6hMO!HJN8GdpBKg_$B zwDHJF6;z?h<;GXFZan8W{XFNPpOj!(&I1`&kWO86p?Xz`a$`7qV7Xqev|7nn_lQuX ziGpU1MMYt&5dE2A62iX3;*0WzNB9*nSTzI%62A+N?f?;S>N@8M=|ef3gtQTIA*=yq zQAAjOqa!CkHOQo4?TsqrrsJLclXcP?dlAVv?v`}YUjo1Htt;6djP@NPFH+&p1I+f_ z)Y279{7OWomY8baT(4TAOlz1OyD{4P?(DGv3XyJTA2IXe=kqD)^h(@*E3{I~w;ws8 z)ZWv7E)pbEM zd3MOXRH3mQhks9 zv6{s;k0y5vrcjXaVfw8^>YyPo=oIqd5IGI{)+TZq5Z5O&hXAw%ZlL}^6FugH;-%vP zAaKFtt3i^ag226=f0YjzdPn6|4(C2sC5wHFX{7QF!tG1E-JFA`>eZ`}$ymcRJK?0c zN363o{&ir)QySOFY0vcu6)kX#;l??|7o{HBDVJN+17rt|w3;(C_1b>d;g9Gp=8YVl zYTtA52@!7AUEkTm@P&h#eg+F*lR zQ7iotZTcMR1frJ0*V@Hw__~CL>_~2H2cCtuzYIUD24=Cv!1j6s{QS!v=PzwQ(a0HS zBKx04KA}-Ue+%9d`?PG*hIij@54RDSQpA7|>qYVIrK_G6%6;#ZkR}NjUgmGju)2F`>|WJoljo)DJgZr4eo1k1i1+o z1D{>^RlpIY8OUaOEf5EBu%a&~c5aWnqM zxBpJq98f=%M^{4mm~5`CWl%)nFR64U{(chmST&2jp+-r z3675V<;Qi-kJud%oWnCLdaU-)xTnMM%rx%Jw6v@=J|Ir=4n-1Z23r-EVf91CGMGNz zb~wyv4V{H-hkr3j3WbGnComiqmS0vn?n?5v2`Vi>{Ip3OZUEPN7N8XeUtF)Ry6>y> zvn0BTLCiqGroFu|m2zG-;Xb6;W`UyLw)@v}H&(M}XCEVXZQoWF=Ykr5lX3XWwyNyF z#jHv)A*L~2BZ4lX?AlN3X#axMwOC)PoVy^6lCGse9bkGjb=qz%kDa6}MOmSwK`cVO zt(e*MW-x}XtU?GY5}9{MKhRhYOlLhJE5=ca+-RmO04^ z66z{40J=s=ey9OCdc(RCzy zd7Zr1%!y3}MG(D=wM_ebhXnJ@MLi7cImDkhm0y{d-Vm81j`0mbi4lF=eirlr)oW~a zCd?26&j^m4AeXEsIUXiTal)+SPM4)HX%%YWF1?(FV47BaA`h9m67S9x>hWMVHx~Hg z1meUYoLL(p@b3?x|9DgWeI|AJ`Ia84*P{Mb%H$ZRROouR4wZhOPX15=KiBMHl!^JnCt$Az`KiH^_d>cev&f zaG2>cWf$=A@&GP~DubsgYb|L~o)cn5h%2`i^!2)bzOTw2UR!>q5^r&2Vy}JaWFUQE04v>2;Z@ZPwXr?y&G(B^@&y zsd6kC=hHdKV>!NDLIj+3rgZJ|dF`%N$DNd;B)9BbiT9Ju^Wt%%u}SvfM^=|q-nxDG zuWCQG9e#~Q5cyf8@y76#kkR^}{c<_KnZ0QsZcAT|YLRo~&tU|N@BjxOuy`#>`X~Q< z?R?-Gsk$$!oo(BveQLlUrcL#eirhgBLh`qHEMg`+sR1`A=1QX7)ZLMRT+GBy?&mM8 zQG^z-!Oa&J-k7I(3_2#Q6Bg=NX<|@X&+YMIOzfEO2$6Mnh}YV!m!e^__{W@-CTprr zbdh3f=BeCD$gHwCrmwgM3LAv3!Mh$wM)~KWzp^w)Cu6roO7uUG5z*}i0_0j47}pK; ztN530`ScGatLOL06~zO)Qmuv`h!gq5l#wx(EliKe&rz-5qH(hb1*fB#B+q`9=jLp@ zOa2)>JTl7ovxMbrif`Xe9;+fqB1K#l=Dv!iT;xF zdkCvS>C5q|O;}ns3AgoE({Ua-zNT-9_5|P0iANmC6O76Sq_(AN?UeEQJ>#b54fi3k zFmh+P%b1x3^)0M;QxXLP!BZ^h|AhOde*{9A=f3|Xq*JAs^Y{eViF|=EBfS6L%k4ip zk+7M$gEKI3?bQg?H3zaE@;cyv9kv;cqK$VxQbFEsy^iM{XXW0@2|DOu$!-k zSFl}Y=jt-VaT>Cx*KQnHTyXt}f9XswFB9ibYh+k2J!ofO+nD?1iw@mwtrqI4_i?nE zhLkPp41ED62me}J<`3RN80#vjW;wt`pP?%oQ!oqy7`miL>d-35a=qotK$p{IzeSk# ze_$CFYp_zIkrPFVaW^s#U4xT1lI^A0IBe~Y<4uS%zSV=wcuLr%gQT=&5$&K*bwqx| zWzCMiz>7t^Et@9CRUm9E+@hy~sBpm9fri$sE1zgLU((1?Yg{N1Sars=DiW&~Zw=3I zi7y)&oTC?UWD2w97xQ&5vx zRXEBGeJ(I?Y}eR0_O{$~)bMJRTsNUPIfR!xU9PE7A>AMNr_wbrFK>&vVw=Y;RH zO$mlpmMsQ}-FQ2cSj7s7GpC+~^Q~dC?y>M}%!-3kq(F3hGWo9B-Gn02AwUgJ>Z-pKOaj zysJBQx{1>Va=*e@sLb2z&RmQ7ira;aBijM-xQ&cpR>X3wP^foXM~u1>sv9xOjzZpX z0K;EGouSYD~oQ&lAafj3~EaXfFShC+>VsRlEMa9cg9i zFxhCKO}K0ax6g4@DEA?dg{mo>s+~RPI^ybb^u--^nTF>**0l5R9pocwB?_K)BG_)S zyLb&k%XZhBVr7U$wlhMqwL)_r&&n%*N$}~qijbkfM|dIWP{MyLx}X&}ES?}7i;9bW zmTVK@zR)7kE2+L42Q`n4m0VVg5l5(W`SC9HsfrLZ=v%lpef=Gj)W59VTLe+Z$8T8i z4V%5+T0t8LnM&H>Rsm5C%qpWBFqgTwL{=_4mE{S3EnBXknM&u8n}A^IIM4$s3m(Rd z>zq=CP-!9p9es2C*)_hoL@tDYABn+o#*l;6@7;knWIyDrt5EuakO99S$}n((Fj4y} zD!VvuRzghcE{!s;jC*<_H$y6!6QpePo2A3ZbX*ZzRnQq*b%KK^NF^z96CHaWmzU@f z#j;y?X=UP&+YS3kZx7;{ zDA{9(wfz7GF`1A6iB6fnXu0?&d|^p|6)%3$aG0Uor~8o? z*e}u#qz7Ri?8Uxp4m_u{a@%bztvz-BzewR6bh*1Xp+G=tQGpcy|4V_&*aOqu|32CM zz3r*E8o8SNea2hYJpLQ-_}R&M9^%@AMx&`1H8aDx4j%-gE+baf2+9zI*+Pmt+v{39 zDZ3Ix_vPYSc;Y;yn68kW4CG>PE5RoaV0n@#eVmk?p$u&Fy&KDTy!f^Hy6&^-H*)#u zdrSCTJPJw?(hLf56%2;_3n|ujUSJOU8VPOTlDULwt0jS@j^t1WS z!n7dZIoT+|O9hFUUMbID4Ec$!cc($DuQWkocVRcYSikFeM&RZ=?BW)mG4?fh#)KVG zcJ!<=-8{&MdE)+}?C8s{k@l49I|Zwswy^ZN3;E!FKyglY~Aq?4m74P-0)sMTGXqd5(S<-(DjjM z&7dL-Mr8jhUCAG$5^mI<|%`;JI5FVUnNj!VO2?Jiqa|c2;4^n!R z`5KK0hyB*F4w%cJ@Un6GC{mY&r%g`OX|1w2$B7wxu97%<@~9>NlXYd9RMF2UM>(z0 zouu4*+u+1*k;+nFPk%ly!nuMBgH4sL5Z`@Rok&?Ef=JrTmvBAS1h?C0)ty5+yEFRz zY$G=coQtNmT@1O5uk#_MQM1&bPPnspy5#>=_7%WcEL*n$;sSAZcXxMpcXxLe;_mLA z5F_paad+bGZV*oh@8h0(|D2P!q# zTHjmiphJ=AazSeKQPkGOR-D8``LjzToyx{lfK-1CDD6M7?pMZOdLKFtjZaZMPk4}k zW)97Fh(Z+_Fqv(Q_CMH-YYi?fR5fBnz7KOt0*t^cxmDoIokc=+`o# zrud|^h_?KW=Gv%byo~(Ln@({?3gnd?DUf-j2J}|$Mk>mOB+1{ZQ8HgY#SA8END(Zw z3T+W)a&;OO54~m}ffemh^oZ!Vv;!O&yhL0~hs(p^(Yv=(3c+PzPXlS5W79Er8B1o* z`c`NyS{Zj_mKChj+q=w)B}K za*zzPhs?c^`EQ;keH{-OXdXJet1EsQ)7;{3eF!-t^4_Srg4(Ot7M*E~91gwnfhqaM zNR7dFaWm7MlDYWS*m}CH${o?+YgHiPC|4?X?`vV+ws&Hf1ZO-w@OGG^o4|`b{bLZj z&9l=aA-Y(L11!EvRjc3Zpxk7lc@yH1e$a}8$_-r$)5++`_eUr1+dTb@ zU~2P1HM#W8qiNN3b*=f+FfG1!rFxnNlGx{15}BTIHgxO>Cq4 z;#9H9YjH%>Z2frJDJ8=xq>Z@H%GxXosS@Z>cY9ppF+)e~t_hWXYlrO6)0p7NBMa`+ z^L>-#GTh;k_XnE)Cgy|0Dw;(c0* zSzW14ZXozu)|I@5mRFF1eO%JM=f~R1dkNpZM+Jh(?&Zje3NgM{2ezg1N`AQg5%+3Y z64PZ0rPq6;_)Pj-hyIOgH_Gh`1$j1!jhml7ksHA1`CH3FDKiHLz+~=^u@kUM{ilI5 z^FPiJ7mSrzBs9{HXi2{sFhl5AyqwUnU{sPcUD{3+l-ZHAQ)C;c$=g1bdoxeG(5N01 zZy=t8i{*w9m?Y>V;uE&Uy~iY{pY4AV3_N;RL_jT_QtLFx^KjcUy~q9KcLE3$QJ{!)@$@En{UGG7&}lc*5Kuc^780;7Bj;)X?1CSy*^^ zPP^M)Pr5R>mvp3_hmCtS?5;W^e@5BjE>Cs<`lHDxj<|gtOK4De?Sf0YuK5GX9G93i zMYB{8X|hw|T6HqCf7Cv&r8A$S@AcgG1cF&iJ5=%+x;3yB`!lQ}2Hr(DE8=LuNb~Vs z=FO&2pdc16nD$1QL7j+!U^XWTI?2qQKt3H8=beVTdHHa9=MiJ&tM1RRQ-=+vy!~iz zj3O{pyRhCQ+b(>jC*H)J)%Wq}p>;?@W*Eut@P&?VU+Sdw^4kE8lvX|6czf{l*~L;J zFm*V~UC;3oQY(ytD|D*%*uVrBB}BbAfjK&%S;z;7$w68(8PV_whC~yvkZmX)xD^s6 z{$1Q}q;99W?*YkD2*;)tRCS{q2s@JzlO~<8x9}X<0?hCD5vpydvOw#Z$2;$@cZkYrp83J0PsS~!CFtY%BP=yxG?<@#{7%2sy zOc&^FJxsUYN36kSY)d7W=*1-{7ghPAQAXwT7z+NlESlkUH&8ODlpc8iC*iQ^MAe(B z?*xO4i{zFz^G=^G#9MsLKIN64rRJykiuIVX5~0#vAyDWc9-=6BDNT_aggS2G{B>dD ze-B%d3b6iCfc5{@yz$>=@1kdK^tX9qh0=ocv@9$ai``a_ofxT=>X7_Y0`X}a^M?d# z%EG)4@`^Ej_=%0_J-{ga!gFtji_byY&Vk@T1c|ucNAr(JNr@)nCWj?QnCyvXg&?FW;S-VOmNL6^km_dqiVjJuIASVGSFEos@EVF7St$WE&Z%)`Q##+0 zjaZ=JI1G@0!?l|^+-ZrNd$WrHBi)DA0-Eke>dp=_XpV<%CO_Wf5kQx}5e<90dt>8k zAi00d0rQ821nA>B4JHN7U8Zz=0;9&U6LOTKOaC1FC8GgO&kc=_wHIOGycL@c*$`ce703t%>S}mvxEnD-V!;6c`2(p74V7D0No1Xxt`urE66$0(ThaAZ1YVG#QP$ zy~NN%kB*zhZ2Y!kjn826pw4bh)75*e!dse+2Db(;bN34Uq7bLpr47XTX{8UEeC?2i z*{$`3dP}32${8pF$!$2Vq^gY|#w+VA_|o(oWmQX8^iw#n_crb(K3{69*iU?<%C-%H zuKi)3M1BhJ@3VW>JA`M>L~5*_bxH@Euy@niFrI$82C1}fwR$p2E&ZYnu?jlS}u7W9AyfdXh2pM>78bIt3 z)JBh&XE@zA!kyCDfvZ1qN^np20c1u#%P6;6tU&dx0phT1l=(mw7`u!-0e=PxEjDds z9E}{E!7f9>jaCQhw)&2TtG-qiD)lD(4jQ!q{`x|8l&nmtHkdul# zy+CIF8lKbp9_w{;oR+jSLtTfE+B@tOd6h=QePP>rh4@~!8c;Hlg9m%%&?e`*Z?qz5-zLEWfi>`ord5uHF-s{^bexKAoMEV@9nU z^5nA{f{dW&g$)BAGfkq@r5D)jr%!Ven~Q58c!Kr;*Li#`4Bu_?BU0`Y`nVQGhNZk@ z!>Yr$+nB=`z#o2nR0)V3M7-eVLuY`z@6CT#OTUXKnxZn$fNLPv7w1y7eGE=Qv@Hey`n;`U=xEl|q@CCV^#l)s0ZfT+mUf z^(j5r4)L5i2jnHW4+!6Si3q_LdOLQi<^fu?6WdohIkn79=jf%Fs3JkeXwF(?_tcF? z?z#j6iXEd(wJy4|p6v?xNk-)iIf2oX5^^Y3q3ziw16p9C6B;{COXul%)`>nuUoM*q zzmr|NJ5n)+sF$!yH5zwp=iM1#ZR`O%L83tyog-qh1I z0%dcj{NUs?{myT~33H^(%0QOM>-$hGFeP;U$puxoJ>>o-%Lk*8X^rx1>j|LtH$*)>1C!Pv&gd16%`qw5LdOIUbkNhaBBTo}5iuE%K&ZV^ zAr_)kkeNKNYJRgjsR%vexa~&8qMrQYY}+RbZ)egRg9_$vkoyV|Nc&MH@8L)`&rpqd zXnVaI@~A;Z^c3+{x=xgdhnocA&OP6^rr@rTvCnhG6^tMox$ulw2U7NgUtW%|-5VeH z_qyd47}1?IbuKtqNbNx$HR`*+9o=8`%vM8&SIKbkX9&%TS++x z5|&6P<%=F$C?owUI`%uvUq^yW0>`>yz!|WjzsoB9dT;2Dx8iSuK%%_XPgy0dTD4kd zDXF@&O_vBVVKQq(9YTClUPM30Sk7B!v7nOyV`XC!BA;BIVwphh+c)?5VJ^(C;GoQ$ zvBxr7_p*k$T%I1ke}`U&)$uf}I_T~#3XTi53OX)PoXVgxEcLJgZG^i47U&>LY(l%_ z;9vVDEtuMCyu2fqZeez|RbbIE7@)UtJvgAcVwVZNLccswxm+*L&w`&t=ttT=sv6Aq z!HouSc-24Y9;0q$>jX<1DnnGmAsP))- z^F~o99gHZw`S&Aw7e4id6Lg7kMk-e)B~=tZ!kE7sGTOJ)8@q}np@j7&7Sy{2`D^FH zI7aX%06vKsfJ168QnCM2=l|i>{I{%@gcr>ExM0Dw{PX6ozEuqFYEt z087%MKC;wVsMV}kIiuu9Zz9~H!21d!;Cu#b;hMDIP7nw3xSX~#?5#SSjyyg+Y@xh| z%(~fv3`0j#5CA2D8!M2TrG=8{%>YFr(j)I0DYlcz(2~92?G*?DeuoadkcjmZszH5& zKI@Lis%;RPJ8mNsbrxH@?J8Y2LaVjUIhRUiO-oqjy<&{2X~*f|)YxnUc6OU&5iac= z*^0qwD~L%FKiPmlzi&~a*9sk2$u<7Al=_`Ox^o2*kEv?p`#G(p(&i|ot8}T;8KLk- zPVf_4A9R`5^e`Om2LV*cK59EshYXse&IoByj}4WZaBomoHAPKqxRKbPcD`lMBI)g- zeMRY{gFaUuecSD6q!+b5(?vAnf>c`Z(8@RJy%Ulf?W~xB1dFAjw?CjSn$ph>st5bc zUac1aD_m6{l|$#g_v6;=32(mwpveQDWhmjR7{|B=$oBhz`7_g7qNp)n20|^^op3 zSfTdWV#Q>cb{CMKlWk91^;mHap{mk)o?udk$^Q^^u@&jd zfZ;)saW6{e*yoL6#0}oVPb2!}r{pAUYtn4{P~ES9tTfC5hXZnM{HrC8^=Pof{G4%Bh#8 ze~?C9m*|fd8MK;{L^!+wMy>=f^8b&y?yr6KnTq28$pFMBW9Oy7!oV5z|VM$s-cZ{I|Xf@}-)1=$V&x7e;9v81eiTi4O5-vs?^5pCKy2l>q);!MA zS!}M48l$scB~+Umz}7NbwyTn=rqt@`YtuwiQSMvCMFk2$83k50Q>OK5&fe*xCddIm)3D0I6vBU<+!3=6?(OhkO|b4fE_-j zimOzyfBB_*7*p8AmZi~X2bgVhyPy>KyGLAnOpou~sx9)S9%r)5dE%ADs4v%fFybDa_w*0?+>PsEHTbhKK^G=pFz z@IxLTCROWiKy*)cV3y%0FwrDvf53Ob_XuA1#tHbyn%Ko!1D#sdhBo`;VC*e1YlhrC z?*y3rp86m#qI|qeo8)_xH*G4q@70aXN|SP+6MQ!fJQqo1kwO_v7zqvUfU=Gwx`CR@ zRFb*O8+54%_8tS(ADh}-hUJzE`s*8wLI>1c4b@$al)l}^%GuIXjzBK!EWFO8W`>F^ ze7y#qPS0NI7*aU)g$_ziF(1ft;2<}6Hfz10cR8P}67FD=+}MfhrpOkF3hFhQu;Q1y zu%=jJHTr;0;oC94Hi@LAF5quAQ(rJG(uo%BiRQ@8U;nhX)j0i?0SL2g-A*YeAqF>RVCBOTrn{0R27vu}_S zS>tX4!#&U4W;ikTE!eFH+PKw%p+B(MR2I%n#+m0{#?qRP_tR@zpgCb=4rcrL!F=;A zh%EIF8m6%JG+qb&mEfuFTLHSxUAZEvC-+kvZKyX~SA3Umt`k}}c!5dy?-sLIM{h@> z!2=C)@nx>`;c9DdwZ&zeUc(7t<21D7qBj!|1^Mp1eZ6)PuvHx+poKSDCSBMFF{bKy z;9*&EyKitD99N}%mK8431rvbT+^%|O|HV23{;RhmS{$5tf!bIPoH9RKps`-EtoW5h zo6H_!s)Dl}2gCeGF6>aZtah9iLuGd19^z0*OryPNt{70RvJSM<#Ox9?HxGg04}b^f zrVEPceD%)#0)v5$YDE?f`73bQ6TA6wV;b^x*u2Ofe|S}+q{s5gr&m~4qGd!wOu|cZ||#h_u=k*fB;R6&k?FoM+c&J;ISg70h!J7*xGus)ta4veTdW)S^@sU@ z4$OBS=a~@F*V0ECic;ht4@?Jw<9kpjBgHfr2FDPykCCz|v2)`JxTH55?b3IM={@DU z!^|9nVO-R#s{`VHypWyH0%cs;0GO3E;It6W@0gX6wZ%W|Dzz&O%m17pa19db(er}C zUId1a4#I+Ou8E1MU$g=zo%g7K(=0Pn$)Rk z<4T2u<0rD)*j+tcy2XvY+0 z0d2pqm4)4lDewsAGThQi{2Kc3&C=|OQF!vOd#WB_`4gG3@inh-4>BoL!&#ij8bw7? zqjFRDaQz!J-YGitV4}$*$hg`vv%N)@#UdzHFI2E<&_@0Uw@h_ZHf}7)G;_NUD3@18 zH5;EtugNT0*RXVK*by>WS>jaDDfe!A61Da=VpIK?mcp^W?!1S2oah^wowRnrYjl~`lgP-mv$?yb6{{S55CCu{R z$9;`dyf0Y>uM1=XSl_$01Lc1Iy68IosWN8Q9Op=~I(F<0+_kKfgC*JggjxNgK6 z-3gQm6;sm?J&;bYe&(dx4BEjvq}b`OT^RqF$J4enP1YkeBK#>l1@-K`ajbn05`0J?0daOtnzh@l3^=BkedW1EahZlRp;`j*CaT;-21&f2wU z+Nh-gc4I36Cw+;3UAc<%ySb`#+c@5y ze~en&bYV|kn?Cn|@fqmGxgfz}U!98$=drjAkMi`43I4R%&H0GKEgx-=7PF}y`+j>r zg&JF`jomnu2G{%QV~Gf_-1gx<3Ky=Md9Q3VnK=;;u0lyTBCuf^aUi?+1+`4lLE6ZK zT#(Bf`5rmr(tgTbIt?yA@y`(Ar=f>-aZ}T~>G32EM%XyFvhn&@PWCm#-<&ApLDCXT zD#(9m|V(OOo7PmE@`vD4$S5;+9IQm19dd zvMEU`)E1_F+0o0-z>YCWqg0u8ciIknU#{q02{~YX)gc_u;8;i233D66pf(IkTDxeN zL=4z2)?S$TV9=ORVr&AkZMl<4tTh(v;Ix1{`pPVqI3n2ci&4Dg+W|N8TBUfZ*WeLF zqCH_1Q0W&f9T$lx3CFJ$o@Lz$99 zW!G&@zFHxTaP!o#z^~xgF|(vrHz8R_r9eo;TX9}2ZyjslrtH=%6O)?1?cL&BT(Amp zTGFU1%%#xl&6sH-UIJk_PGk_McFn7=%yd6tAjm|lnmr8bE2le3I~L{0(ffo}TQjyo zHZZI{-}{E4ohYTlZaS$blB!h$Jq^Rf#(ch}@S+Ww&$b);8+>g84IJcLU%B-W?+IY& zslcZIR>+U4v3O9RFEW;8NpCM0w1ROG84=WpKxQ^R`{=0MZCubg3st z48AyJNEvyxn-jCPTlTwp4EKvyEwD3e%kpdY?^BH0!3n6Eb57_L%J1=a*3>|k68A}v zaW`*4YitylfD}ua8V)vb79)N_Ixw_mpp}yJGbNu+5YYOP9K-7nf*jA1#<^rb4#AcS zKg%zCI)7cotx}L&J8Bqo8O1b0q;B1J#B5N5Z$Zq=wX~nQFgUfAE{@u0+EnmK{1hg> zC{vMfFLD;L8b4L+B51&LCm|scVLPe6h02rws@kGv@R+#IqE8>Xn8i|vRq_Z`V;x6F zNeot$1Zsu`lLS92QlLWF54za6vOEKGYQMdX($0JN*cjG7HP&qZ#3+bEN$8O_PfeAb z0R5;=zXac2IZ?fxu59?Nka;1lKm|;0)6|#RxkD05P5qz;*AL@ig!+f=lW5^Jbag%2 z%9@iM0ph$WFlxS!`p31t92z~TB}P-*CS+1Oo_g;7`6k(Jyj8m8U|Q3Sh7o-Icp4kV zK}%qri5>?%IPfamXIZ8pXbm-#{ytiam<{a5A+3dVP^xz!Pvirsq7Btv?*d7eYgx7q zWFxrzb3-%^lDgMc=Vl7^={=VDEKabTG?VWqOngE`Kt7hs236QKidsoeeUQ_^FzsXjprCDd@pW25rNx#6x&L6ZEpoX9Ffzv@olnH3rGOSW( zG-D|cV0Q~qJ>-L}NIyT?T-+x+wU%;+_GY{>t(l9dI%Ximm+Kmwhee;FK$%{dnF;C% zFjM2&$W68Sz#d*wtfX?*WIOXwT;P6NUw}IHdk|)fw*YnGa0rHx#paG!m=Y6GkS4VX zX`T$4eW9k1W!=q8!(#8A9h67fw))k_G)Q9~Q1e3f`aV@kbcSv7!priDUN}gX(iXTy zr$|kU0Vn%*ylmyDCO&G0Z3g>%JeEPFAW!5*H2Ydl>39w3W+gEUjL&vrRs(xGP{(ze zy7EMWF14@Qh>X>st8_029||TP0>7SG9on_xxeR2Iam3G~Em$}aGsNt$iES9zFa<3W zxtOF*!G@=PhfHO!=9pVPXMUVi30WmkPoy$02w}&6A7mF)G6-`~EVq5CwD2`9Zu`kd)52``#V zNSb`9dG~8(dooi1*-aSMf!fun7Sc`-C$-E(3BoSC$2kKrVcI!&yC*+ff2+C-@!AT_ zsvlAIV+%bRDfd{R*TMF><1&_a%@yZ0G0lg2K;F>7b+7A6pv3-S7qWIgx+Z?dt8}|S z>Qbb6x(+^aoV7FQ!Ph8|RUA6vXWQH*1$GJC+wXLXizNIc9p2yLzw9 z0=MdQ!{NnOwIICJc8!+Jp!zG}**r#E!<}&Te&}|B4q;U57$+pQI^}{qj669zMMe_I z&z0uUCqG%YwtUc8HVN7?0GHpu=bL7&{C>hcd5d(iFV{I5c~jpX&!(a{yS*4MEoYXh z*X4|Y@RVfn;piRm-C%b@{0R;aXrjBtvx^HO;6(>i*RnoG0Rtcd25BT6edxTNOgUAOjn zJ2)l{ipj8IP$KID2}*#F=M%^n&=bA0tY98@+2I+7~A&T-tw%W#3GV>GTmkHaqftl)#+E zMU*P(Rjo>8%P@_@#UNq(_L{}j(&-@1iY0TRizhiATJrnvwSH0v>lYfCI2ex^><3$q znzZgpW0JlQx?JB#0^^s-Js1}}wKh6f>(e%NrMwS`Q(FhazkZb|uyB@d%_9)_xb$6T zS*#-Bn)9gmobhAtvBmL+9H-+0_0US?g6^TOvE8f3v=z3o%NcPjOaf{5EMRnn(_z8- z$|m0D$FTU zDy;21v-#0i)9%_bZ7eo6B9@Q@&XprR&oKl4m>zIj-fiRy4Dqy@VVVs?rscG| zmzaDQ%>AQTi<^vYCmv#KOTd@l7#2VIpsj?nm_WfRZzJako`^uU%Nt3e;cU*y*|$7W zLm%fX#i_*HoUXu!NI$ey>BA<5HQB=|nRAwK!$L#n-Qz;~`zACig0PhAq#^5QS<8L2 zS3A+8%vbVMa7LOtTEM?55apt(DcWh#L}R^P2AY*c8B}Cx=6OFAdMPj1f>k3#^#+Hk z6uW1WJW&RlBRh*1DLb7mJ+KO>!t^t8hX1#_Wk`gjDio9)9IGbyCAGI4DJ~orK+YRv znjxRMtshZQHc$#Y-<-JOV6g^Cr@odj&Xw5B(FmI)*qJ9NHmIz_r{t)TxyB`L-%q5l ztzHgD;S6cw?7Atg*6E1!c6*gPRCb%t7D%z<(xm+K{%EJNiI2N0l8ud0Ch@_av_RW? zIr!nO4dL5466WslE6MsfMss7<)-S!e)2@r2o=7_W)OO`~CwklRWzHTfpB)_HYwgz=BzLhgZ9S<{nLBOwOIgJU=94uj6r!m>Xyn9>&xP+=5!zG_*yEoRgM0`aYts z^)&8(>z5C-QQ*o_s(8E4*?AX#S^0)aqB)OTyX>4BMy8h(cHjA8ji1PRlox@jB*1n? zDIfyDjzeg91Ao(;Q;KE@zei$}>EnrF6I}q&Xd=~&$WdDsyH0H7fJX|E+O~%LS*7^Q zYzZ4`pBdY{b7u72gZm6^5~O-57HwzwAz{)NvVaowo`X02tL3PpgLjwA`^i9F^vSpN zAqH3mRjG8VeJNHZ(1{%!XqC+)Z%D}58Qel{_weSEHoygT9pN@i zi=G;!Vj6XQk2tuJC>lza%ywz|`f7TIz*EN2Gdt!s199Dr4Tfd_%~fu8gXo~|ogt5Q zlEy_CXEe^BgsYM^o@L?s33WM14}7^T(kqohOX_iN@U?u;$l|rAvn{rwy>!yfZw13U zB@X9)qt&4;(C6dP?yRsoTMI!j-f1KC!<%~i1}u7yLXYn)(#a;Z6~r>hp~kfP));mi zcG%kdaB9H)z9M=H!f>kM->fTjRVOELNwh1amgKQT=I8J66kI)u_?0@$$~5f`u%;zl zC?pkr^p2Fe=J~WK%4ItSzKA+QHqJ@~m|Cduv=Q&-P8I5rQ-#G@bYH}YJr zUS(~(w|vKyU(T(*py}jTUp%I%{2!W!K(i$uvotcPjVddW z8_5HKY!oBCwGZcs-q`4Yt`Zk~>K?mcxg51wkZlX5e#B08I75F7#dgn5yf&Hrp`*%$ zQ;_Qg>TYRzBe$x=T(@WI9SC!ReSas9vDm(yslQjBJZde5z8GDU``r|N(MHcxNopGr z_}u39W_zwWDL*XYYt>#Xo!9kL#97|EAGyGBcRXtLTd59x%m=3i zL^9joWYA)HfL15l9%H?q`$mY27!<9$7GH(kxb%MV>`}hR4a?+*LH6aR{dzrX@?6X4 z3e`9L;cjqYb`cJmophbm(OX0b)!AFG?5`c#zLagzMW~o)?-!@e80lvk!p#&CD8u5_r&wp4O0zQ>y!k5U$h_K;rWGk=U)zX!#@Q%|9g*A zWx)qS1?fq6X<$mQTB$#3g;;5tHOYuAh;YKSBz%il3Ui6fPRv#v62SsrCdMRTav)Sg zTq1WOu&@v$Ey;@^+_!)cf|w_X<@RC>!=~+A1-65O0bOFYiH-)abINwZvFB;hJjL_$ z(9iScmUdMp2O$WW!520Hd0Q^Yj?DK%YgJD^ez$Z^?@9@Ab-=KgW@n8nC&88)TDC+E zlJM)L3r+ZJfZW_T$;Imq*#2<(j+FIk8ls7)WJ6CjUu#r5PoXxQs4b)mZza<8=v{o)VlLRM<9yw^0En#tXAj`Sylxvki{<1DPe^ zhjHwx^;c8tb?Vr$6ZB;$Ff$+3(*oinbwpN-#F)bTsXq@Sm?43MC#jQ~`F|twI=7oC zH4TJtu#;ngRA|Y~w5N=UfMZi?s0%ZmKUFTAye&6Y*y-%c1oD3yQ%IF2q2385Zl+=> zfz=o`Bedy|U;oxbyb^rB9ixG{Gb-{h$U0hVe`J;{ql!s_OJ_>>eoQn(G6h7+b^P48 zG<=Wg2;xGD-+d@UMZ!c;0>#3nws$9kIDkK13IfloGT@s14AY>&>>^#>`PT7GV$2Hp zN<{bN*ztlZu_%W=&3+=#3bE(mka6VoHEs~0BjZ$+=0`a@R$iaW)6>wp2w)=v2@|2d z%?34!+iOc5S@;AAC4hELWLH56RGxo4jw8MDMU0Wk2k_G}=Vo(>eRFo(g3@HjG|`H3 zm8b*dK=moM*oB<)*A$M9!!5o~4U``e)wxavm@O_R(`P|u%9^LGi(_%IF<6o;NLp*0 zKsfZ0#24GT8(G`i4UvoMh$^;kOhl?`0yNiyrC#HJH=tqOH^T_d<2Z+ zeN>Y9Zn!X4*DMCK^o75Zk2621bdmV7Rx@AX^alBG4%~;G_vUoxhfhFRlR&+3WwF^T zaL)8xPq|wCZoNT^>3J0K?e{J-kl+hu2rZI>CUv#-z&u@`hjeb+bBZ>bcciQVZ{SbW zez04s9oFEgc8Z+Kp{XFX`MVf-s&w9*dx7wLen(_@y34}Qz@&`$2+osqfxz4&d}{Ql z*g1ag00Gu+$C`0avds{Q65BfGsu9`_`dML*rX~hyWIe$T>CsPRoLIr%MTk3pJ^2zH1qub1MBzPG}PO;Wmav9w%F7?%l=xIf#LlP`! z_Nw;xBQY9anH5-c8A4mME}?{iewjz(Sq-29r{fV;Fc>fv%0!W@(+{={Xl-sJ6aMoc z)9Q+$bchoTGTyWU_oI19!)bD=IG&OImfy;VxNXoIO2hYEfO~MkE#IXTK(~?Z&!ae! zl8z{D&2PC$Q*OBC(rS~-*-GHNJ6AC$@eve>LB@Iq;jbBZj`wk4|LGogE||Ie=M5g= z9d`uYQ1^Sr_q2wmZE>w2WG)!F%^KiqyaDtIAct?}D~JP4shTJy5Bg+-(EA8aXaxbd~BKMtTf2iQ69jD1o* zZF9*S3!v-TdqwK$%&?91Sh2=e63;X0Lci@n7y3XOu2ofyL9^-I767eHESAq{m+@*r zbVDx!FQ|AjT;!bYsXv8ilQjy~Chiu&HNhFXt3R_6kMC8~ChEFqG@MWu#1Q1#=~#ix zrkHpJre_?#r=N0wv`-7cHHqU`phJX2M_^{H0~{VP79Dv{6YP)oA1&TSfKPEPZn2)G z9o{U1huZBLL;Tp_0OYw@+9z(jkrwIGdUrOhKJUbwy?WBt zlIK)*K0lQCY0qZ!$%1?3A#-S70F#YyUnmJF*`xx?aH5;gE5pe-15w)EB#nuf6B*c~ z8Z25NtY%6Wlb)bUA$w%HKs5$!Z*W?YKV-lE0@w^{4vw;J>=rn?u!rv$&eM+rpU6rc=j9>N2Op+C{D^mospMCjF2ZGhe4eADA#skp2EA26%p3Ex9wHW8l&Y@HX z$Qv)mHM}4*@M*#*ll5^hE9M^=q~eyWEai*P;4z<9ZYy!SlNE5nlc7gm;M&Q zKhKE4d*%A>^m0R?{N}y|i6i^k>^n4(wzKvlQeHq{l&JuFD~sTsdhs`(?lFK@Q{pU~ zb!M3c@*3IwN1RUOVjY5>uT+s-2QLWY z4T2>fiSn>>Fob+%B868-v9D@AfWr#M8eM6w#eAlhc#zk6jkLxGBGk`E3$!A@*am!R zy>29&ptYK6>cvP`b!syNp)Q$0UOW|-O@)8!?94GOYF_}+zlW%fCEl|Tep_zx05g6q z>tp47e-&R*hSNe{6{H!mL?+j$c^TXT{C&@T-xIaesNCl05 z9SLb@q&mSb)I{VXMaiWa3PWj=Ed!>*GwUe;^|uk=Pz$njNnfFY^MM>E?zqhf6^{}0 zx&~~dA5#}1ig~7HvOQ#;d9JZBeEQ+}-~v$at`m!(ai z$w(H&mWCC~;PQ1$%iuz3`>dWeb3_p}X>L2LK%2l59Tyc}4m0>9A!8rhoU3m>i2+hl zx?*qs*c^j}+WPs>&v1%1Ko8_ivAGIn@QK7A`hDz-Emkcgv2@wTbYhkiwX2l=xz*XG zaiNg+j4F-I>9v+LjosI-QECrtKjp&0T@xIMKVr+&)gyb4@b3y?2CA?=ooN zT#;rU86WLh(e@#mF*rk(NV-qSIZyr z$6!ZUmzD)%yO-ot`rw3rp6?*_l*@Z*IB0xn4|BGPWHNc-1ZUnNSMWmDh=EzWJRP`) zl%d%J613oXzh5;VY^XWJi{lB`f#u+ThvtP7 zq(HK<4>tw(=yzSBWtYO}XI`S1pMBe3!jFxBHIuwJ(@%zdQFi1Q_hU2eDuHqXte7Ki zOV55H2D6u#4oTfr7|u*3p75KF&jaLEDpxk!4*bhPc%mpfj)Us3XIG3 zIKMX^s^1wt8YK7Ky^UOG=w!o5e7W-<&c|fw2{;Q11vm@J{)@N3-p1U>!0~sKWHaL= zWV(0}1IIyt1p%=_-Fe5Kfzc71wg}`RDDntVZv;4!=&XXF-$48jS0Sc;eDy@Sg;+{A zFStc{dXT}kcIjMXb4F7MbX~2%i;UrBxm%qmLKb|2=?uPr00-$MEUIGR5+JG2l2Nq` zkM{{1RO_R)+8oQ6x&-^kCj)W8Z}TJjS*Wm4>hf+4#VJP)OBaDF%3pms7DclusBUw} z{ND#!*I6h85g6DzNvdAmnwWY{&+!KZM4DGzeHI?MR@+~|su0{y-5-nICz_MIT_#FE zm<5f3zlaKq!XyvY3H`9s&T};z!cK}G%;~!rpzk9-6L}4Rg7vXtKFsl}@sT#U#7)x- z7UWue5sa$R>N&b{J61&gvKcKlozH*;OjoDR+elkh|4bJ!_3AZNMOu?n9&|L>OTD78 z^i->ah_Mqc|Ev)KNDzfu1P3grBIM#%`QZqj5W{qu(HocQhjyS;UINoP`{J+DvV?|1 z_sw6Yr3z6%e7JKVDY<$P=M)dbk@~Yw9|2!Cw!io3%j92wTD!c^e9Vj+7VqXo3>u#= zv#M{HHJ=e$X5vQ>>ML?E8#UlmvJgTnb73{PSPTf*0)mcj6C z{KsfUbDK|F$E(k;ER%8HMdDi`=BfpZzP3cl5yJHu;v^o2FkHNk;cXc17tL8T!CsYI zfeZ6sw@;8ia|mY_AXjCS?kUfxdjDB28)~Tz1dGE|{VfBS9`0m2!m1yG?hR})er^pl4c@9Aq+|}ZlDaHL)K$O| z%9Jp-imI-Id0|(d5{v~w6mx)tUKfbuVD`xNt04Mry%M+jXzE>4(TBsx#&=@wT2Vh) z1yeEY&~17>0%P(eHP0HB^|7C+WJxQBTG$uyOWY@iDloRIb-Cf!p<{WQHR!422#F34 zG`v|#CJ^G}y9U*7jgTlD{D&y$Iv{6&PYG>{Ixg$pGk?lWrE#PJ8KunQC@}^6OP!|< zS;}p3to{S|uZz%kKe|;A0bL0XxPB&Q{J(9PyX`+Kr`k~r2}yP^ND{8!v7Q1&vtk& z2Y}l@J@{|2`oA%sxvM9i0V+8IXrZ4;tey)d;LZI70Kbim<4=WoTPZy=Yd|34v#$Kh zx|#YJ8s`J>W&jt#GcMpx84w2Z3ur-rK7gf-p5cE)=w1R2*|0mj12hvapuUWM0b~dG zMg9p8FmAZI@i{q~0@QuY44&mMUNXd7z>U58shA3o`p5eVLpq>+{(<3->DWuSFVZwC zxd50Uz(w~LxC4}bgag#q#NNokK@yNc+Q|Ap!u>Ddy+df>v;j@I12CDNN9do+0^n8p zMQs7X#+FVF0C5muGfN{r0|Nkql%BQT|K(DDNdR2pzM=_ea5+GO|J67`05AV92t@4l z0Qno0078PIHdaQGHZ~Scw!dzgqjK~3B7kf>BcP__&lLyU(cu3B^uLo%{j|Mb0NR)tkeT7Hcwp4O# z)yzu>cvG(d9~0a^)eZ;;%3ksk@F&1eEBje~ zW+-_s)&RgiweQc!otF>4%vbXKaOU41{!hw?|2`Ld3I8$&#WOsq>EG)1ANb!{N4z9@ zsU!bPG-~-bqCeIDzo^Q;gnucB{tRzm{ZH^Orphm2U+REA!*<*J6YQV83@&xoDl%#wnl5qcBqCcAF-vX5{30}(oJrnSH z{RY85hylK2dMOh2%oO1J8%)0?8TOL%rS8)+CsDv}aQ>4D)Jv+DLK)9gI^n-T^$)Tc zFPUD75qJm!Y-KBqj;JP4dV4 z`X{lGmn<)1IGz330}s}Jrjtf{(lnuuNHe5(ezA(pYa=1|Ff-LhPFK8 zyJh_b{yzu0yll6ZkpRzRjezyYivjyjW7QwO;@6X`m;2Apn2EK2!~7S}-*=;5*7K$B z`x(=!^?zgj(-`&ApZJXI09aDLXaT@<;CH=?fBOY5d|b~wBA@@p^K#nxr`)?i?SqTupI_PJ(A3cx`z~9mX_*)>L F{|7XC?P&l2 diff --git a/plugins/image_gallery_saver/android/gradle/wrapper/gradle-wrapper.properties b/plugins/image_gallery_saver/android/gradle/wrapper/gradle-wrapper.properties deleted file mode 100644 index cf40b29a7..000000000 --- a/plugins/image_gallery_saver/android/gradle/wrapper/gradle-wrapper.properties +++ /dev/null @@ -1,6 +0,0 @@ -#Mon Sep 09 11:20:21 CST 2019 -distributionBase=GRADLE_USER_HOME -distributionPath=wrapper/dists -zipStoreBase=GRADLE_USER_HOME -zipStorePath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-7.5-all.zip diff --git a/plugins/image_gallery_saver/android/gradlew b/plugins/image_gallery_saver/android/gradlew deleted file mode 100644 index cccdd3d51..000000000 --- a/plugins/image_gallery_saver/android/gradlew +++ /dev/null @@ -1,172 +0,0 @@ -#!/usr/bin/env sh - -############################################################################## -## -## Gradle start up script for UN*X -## -############################################################################## - -# Attempt to set APP_HOME -# Resolve links: $0 may be a link -PRG="$0" -# Need this for relative symlinks. -while [ -h "$PRG" ] ; do - ls=`ls -ld "$PRG"` - link=`expr "$ls" : '.*-> \(.*\)$'` - if expr "$link" : '/.*' > /dev/null; then - PRG="$link" - else - PRG=`dirname "$PRG"`"/$link" - fi -done -SAVED="`pwd`" -cd "`dirname \"$PRG\"`/" >/dev/null -APP_HOME="`pwd -P`" -cd "$SAVED" >/dev/null - -APP_NAME="Gradle" -APP_BASE_NAME=`basename "$0"` - -# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. -DEFAULT_JVM_OPTS="" - -# Use the maximum available, or set MAX_FD != -1 to use that value. -MAX_FD="maximum" - -warn () { - echo "$*" -} - -die () { - echo - echo "$*" - echo - exit 1 -} - -# OS specific support (must be 'true' or 'false'). -cygwin=false -msys=false -darwin=false -nonstop=false -case "`uname`" in - CYGWIN* ) - cygwin=true - ;; - Darwin* ) - darwin=true - ;; - MINGW* ) - msys=true - ;; - NONSTOP* ) - nonstop=true - ;; -esac - -CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar - -# Determine the Java command to use to start the JVM. -if [ -n "$JAVA_HOME" ] ; then - if [ -x "$JAVA_HOME/jre/sh/java" ] ; then - # IBM's JDK on AIX uses strange locations for the executables - JAVACMD="$JAVA_HOME/jre/sh/java" - else - JAVACMD="$JAVA_HOME/bin/java" - fi - if [ ! -x "$JAVACMD" ] ; then - die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME - -Please set the JAVA_HOME variable in your environment to match the -location of your Java installation." - fi -else - JAVACMD="java" - which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. - -Please set the JAVA_HOME variable in your environment to match the -location of your Java installation." -fi - -# Increase the maximum file descriptors if we can. -if [ "$cygwin" = "false" -a "$darwin" = "false" -a "$nonstop" = "false" ] ; then - MAX_FD_LIMIT=`ulimit -H -n` - if [ $? -eq 0 ] ; then - if [ "$MAX_FD" = "maximum" -o "$MAX_FD" = "max" ] ; then - MAX_FD="$MAX_FD_LIMIT" - fi - ulimit -n $MAX_FD - if [ $? -ne 0 ] ; then - warn "Could not set maximum file descriptor limit: $MAX_FD" - fi - else - warn "Could not query maximum file descriptor limit: $MAX_FD_LIMIT" - fi -fi - -# For Darwin, add options to specify how the application appears in the dock -if $darwin; then - GRADLE_OPTS="$GRADLE_OPTS \"-Xdock:name=$APP_NAME\" \"-Xdock:icon=$APP_HOME/media/gradle.icns\"" -fi - -# For Cygwin, switch paths to Windows format before running java -if $cygwin ; then - APP_HOME=`cygpath --path --mixed "$APP_HOME"` - CLASSPATH=`cygpath --path --mixed "$CLASSPATH"` - JAVACMD=`cygpath --unix "$JAVACMD"` - - # We build the pattern for arguments to be converted via cygpath - ROOTDIRSRAW=`find -L / -maxdepth 1 -mindepth 1 -type d 2>/dev/null` - SEP="" - for dir in $ROOTDIRSRAW ; do - ROOTDIRS="$ROOTDIRS$SEP$dir" - SEP="|" - done - OURCYGPATTERN="(^($ROOTDIRS))" - # Add a user-defined pattern to the cygpath arguments - if [ "$GRADLE_CYGPATTERN" != "" ] ; then - OURCYGPATTERN="$OURCYGPATTERN|($GRADLE_CYGPATTERN)" - fi - # Now convert the arguments - kludge to limit ourselves to /bin/sh - i=0 - for arg in "$@" ; do - CHECK=`echo "$arg"|egrep -c "$OURCYGPATTERN" -` - CHECK2=`echo "$arg"|egrep -c "^-"` ### Determine if an option - - if [ $CHECK -ne 0 ] && [ $CHECK2 -eq 0 ] ; then ### Added a condition - eval `echo args$i`=`cygpath --path --ignore --mixed "$arg"` - else - eval `echo args$i`="\"$arg\"" - fi - i=$((i+1)) - done - case $i in - (0) set -- ;; - (1) set -- "$args0" ;; - (2) set -- "$args0" "$args1" ;; - (3) set -- "$args0" "$args1" "$args2" ;; - (4) set -- "$args0" "$args1" "$args2" "$args3" ;; - (5) set -- "$args0" "$args1" "$args2" "$args3" "$args4" ;; - (6) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" ;; - (7) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" ;; - (8) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" ;; - (9) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" "$args8" ;; - esac -fi - -# Escape application args -save () { - for i do printf %s\\n "$i" | sed "s/'/'\\\\''/g;1s/^/'/;\$s/\$/' \\\\/" ; done - echo " " -} -APP_ARGS=$(save "$@") - -# Collect all arguments for the java command, following the shell quoting and substitution rules -eval set -- $DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS "\"-Dorg.gradle.appname=$APP_BASE_NAME\"" -classpath "\"$CLASSPATH\"" org.gradle.wrapper.GradleWrapperMain "$APP_ARGS" - -# by default we should be in the correct project dir, but when run from Finder on Mac, the cwd is wrong -if [ "$(uname)" = "Darwin" ] && [ "$HOME" = "$PWD" ]; then - cd "$(dirname "$0")" -fi - -exec "$JAVACMD" "$@" diff --git a/plugins/image_gallery_saver/android/gradlew.bat b/plugins/image_gallery_saver/android/gradlew.bat deleted file mode 100644 index f9553162f..000000000 --- a/plugins/image_gallery_saver/android/gradlew.bat +++ /dev/null @@ -1,84 +0,0 @@ -@if "%DEBUG%" == "" @echo off -@rem ########################################################################## -@rem -@rem Gradle startup script for Windows -@rem -@rem ########################################################################## - -@rem Set local scope for the variables with windows NT shell -if "%OS%"=="Windows_NT" setlocal - -set DIRNAME=%~dp0 -if "%DIRNAME%" == "" set DIRNAME=. -set APP_BASE_NAME=%~n0 -set APP_HOME=%DIRNAME% - -@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. -set DEFAULT_JVM_OPTS= - -@rem Find java.exe -if defined JAVA_HOME goto findJavaFromJavaHome - -set JAVA_EXE=java.exe -%JAVA_EXE% -version >NUL 2>&1 -if "%ERRORLEVEL%" == "0" goto init - -echo. -echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. -echo. -echo Please set the JAVA_HOME variable in your environment to match the -echo location of your Java installation. - -goto fail - -:findJavaFromJavaHome -set JAVA_HOME=%JAVA_HOME:"=% -set JAVA_EXE=%JAVA_HOME%/bin/java.exe - -if exist "%JAVA_EXE%" goto init - -echo. -echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% -echo. -echo Please set the JAVA_HOME variable in your environment to match the -echo location of your Java installation. - -goto fail - -:init -@rem Get command-line arguments, handling Windows variants - -if not "%OS%" == "Windows_NT" goto win9xME_args - -:win9xME_args -@rem Slurp the command line arguments. -set CMD_LINE_ARGS= -set _SKIP=2 - -:win9xME_args_slurp -if "x%~1" == "x" goto execute - -set CMD_LINE_ARGS=%* - -:execute -@rem Setup the command line - -set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar - -@rem Execute Gradle -"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %CMD_LINE_ARGS% - -:end -@rem End local scope for the variables with windows NT shell -if "%ERRORLEVEL%"=="0" goto mainEnd - -:fail -rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of -rem the _cmd.exe /c_ return code! -if not "" == "%GRADLE_EXIT_CONSOLE%" exit 1 -exit /b 1 - -:mainEnd -if "%OS%"=="Windows_NT" endlocal - -:omega diff --git a/plugins/image_gallery_saver/android/settings.gradle b/plugins/image_gallery_saver/android/settings.gradle deleted file mode 100644 index 632310dd2..000000000 --- a/plugins/image_gallery_saver/android/settings.gradle +++ /dev/null @@ -1 +0,0 @@ -rootProject.name = 'image_gallery_saver' diff --git a/plugins/image_gallery_saver/android/src/main/AndroidManifest.xml b/plugins/image_gallery_saver/android/src/main/AndroidManifest.xml deleted file mode 100644 index 5f1f81368..000000000 --- a/plugins/image_gallery_saver/android/src/main/AndroidManifest.xml +++ /dev/null @@ -1,3 +0,0 @@ - - diff --git a/plugins/image_gallery_saver/android/src/main/kotlin/com/example/imagegallerysaver/ImageGallerySaverPlugin.kt b/plugins/image_gallery_saver/android/src/main/kotlin/com/example/imagegallerysaver/ImageGallerySaverPlugin.kt deleted file mode 100644 index 649bc4323..000000000 --- a/plugins/image_gallery_saver/android/src/main/kotlin/com/example/imagegallerysaver/ImageGallerySaverPlugin.kt +++ /dev/null @@ -1,246 +0,0 @@ -package com.example.imagegallerysaver - -import androidx.annotation.NonNull -import android.annotation.TargetApi -import android.content.ContentValues -import android.content.Context -import android.content.Intent -import android.graphics.Bitmap -import android.graphics.BitmapFactory -import android.media.MediaScannerConnection -import android.net.Uri -import android.os.Environment -import android.os.Build -import android.provider.MediaStore -import io.flutter.embedding.engine.plugins.FlutterPlugin -import io.flutter.plugin.common.BinaryMessenger -import io.flutter.plugin.common.MethodCall -import io.flutter.plugin.common.MethodChannel -import io.flutter.plugin.common.MethodChannel.MethodCallHandler -import io.flutter.plugin.common.MethodChannel.Result -import java.io.File -import java.io.FileInputStream -import java.io.IOException -import android.text.TextUtils -import android.webkit.MimeTypeMap -import java.io.OutputStream - -class ImageGallerySaverPlugin : FlutterPlugin, MethodCallHandler { - private lateinit var methodChannel: MethodChannel - private var applicationContext: Context? = null - - override fun onAttachedToEngine(@NonNull binding: FlutterPlugin.FlutterPluginBinding) { - this.applicationContext = binding.applicationContext - methodChannel = MethodChannel(binding.binaryMessenger, "image_gallery_saver") - methodChannel.setMethodCallHandler(this) - } - - override fun onMethodCall(@NonNull call: MethodCall,@NonNull result: Result): Unit { - when (call.method) { - "saveImageToGallery" -> { - val image = call.argument("imageBytes") - val quality = call.argument("quality") - val name = call.argument("name") - - result.success( - saveImageToGallery( - BitmapFactory.decodeByteArray( - image ?: ByteArray(0), - 0, - image?.size ?: 0 - ), quality, name - ) - ) - } - - "saveFileToGallery" -> { - val path = call.argument("file") - val name = call.argument("name") - result.success(saveFileToGallery(path, name)) - } - - else -> result.notImplemented() - } - } - - override fun onDetachedFromEngine(@NonNull binding: FlutterPlugin.FlutterPluginBinding) { - applicationContext = null - methodChannel.setMethodCallHandler(null); - } - - private fun generateUri(extension: String = "", name: String? = null): Uri? { - var fileName = name ?: System.currentTimeMillis().toString() - val mimeType = getMIMEType(extension) - val isVideo = mimeType?.startsWith("video")==true - - return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { - // >= android 10 - val uri = when { - isVideo -> MediaStore.Video.Media.EXTERNAL_CONTENT_URI - else -> MediaStore.Images.Media.EXTERNAL_CONTENT_URI - } - - val values = ContentValues().apply { - put(MediaStore.MediaColumns.DISPLAY_NAME, fileName) - put( - MediaStore.MediaColumns.RELATIVE_PATH, when { - isVideo -> Environment.DIRECTORY_MOVIES - else -> Environment.DIRECTORY_PICTURES - } - ) - if (!TextUtils.isEmpty(mimeType)) { - put(when {isVideo -> MediaStore.Video.Media.MIME_TYPE - else -> MediaStore.Images.Media.MIME_TYPE - }, mimeType) - } - } - - applicationContext?.contentResolver?.insert(uri, values) - - } else { - // < android 10 - val storePath = - Environment.getExternalStoragePublicDirectory(when { - isVideo -> Environment.DIRECTORY_MOVIES - else -> Environment.DIRECTORY_PICTURES - }).absolutePath - val appDir = File(storePath).apply { - if (!exists()) { - mkdir() - } - } - - val file = - File(appDir, if (extension.isNotEmpty()) "$fileName.$extension" else fileName) - Uri.fromFile(file) - } - } - - /** - * get file Mime Type - * - * @param extension extension - * @return file Mime Type - */ - private fun getMIMEType(extension: String): String? { - return if (!TextUtils.isEmpty(extension)) { - MimeTypeMap.getSingleton().getMimeTypeFromExtension(extension.lowercase()) - } else { - null - } - } - - /** - * Send storage success notification - * - * @param context context - * @param fileUri file path - */ - private fun sendBroadcast(context: Context, fileUri: Uri?) { - if (Build.VERSION.SDK_INT < Build.VERSION_CODES.Q) { - val mediaScanIntent = Intent(Intent.ACTION_MEDIA_SCANNER_SCAN_FILE) - mediaScanIntent.data = fileUri - context.sendBroadcast(mediaScanIntent) - } - } - - private fun saveImageToGallery( - bmp: Bitmap?, - quality: Int?, - name: String? - ): HashMap { - // check parameters - if (bmp == null || quality == null) { - return SaveResultModel(false, null, "parameters error").toHashMap() - } - // check applicationContext - val context = applicationContext - ?: return SaveResultModel(false, null, "applicationContext null").toHashMap() - var fileUri: Uri? = null - var fos: OutputStream? = null - var success = false - try { - fileUri = generateUri("jpg", name = name) - if (fileUri != null) { - fos = context.contentResolver.openOutputStream(fileUri) - if (fos != null) { - println("ImageGallerySaverPlugin $quality") - bmp.compress(Bitmap.CompressFormat.JPEG, quality, fos) - fos.flush() - success = true - } - } - } catch (e: IOException) { - SaveResultModel(false, null, e.toString()).toHashMap() - } finally { - fos?.close() - bmp.recycle() - } - return if (success) { - sendBroadcast(context, fileUri) - SaveResultModel(fileUri.toString().isNotEmpty(), fileUri.toString(), null).toHashMap() - } else { - SaveResultModel(false, null, "saveImageToGallery fail").toHashMap() - } - } - - private fun saveFileToGallery(filePath: String?, name: String?): HashMap { - // check parameters - if (filePath == null) { - return SaveResultModel(false, null, "parameters error").toHashMap() - } - val context = applicationContext ?: return SaveResultModel( - false, - null, - "applicationContext null" - ).toHashMap() - var fileUri: Uri? = null - var outputStream: OutputStream? = null - var fileInputStream: FileInputStream? = null - var success = false - - try { - val originalFile = File(filePath) - if(!originalFile.exists()) return SaveResultModel(false, null, "$filePath does not exist").toHashMap() - fileUri = generateUri(originalFile.extension, name) - if (fileUri != null) { - outputStream = context.contentResolver?.openOutputStream(fileUri) - if (outputStream != null) { - fileInputStream = FileInputStream(originalFile) - - val buffer = ByteArray(10240) - var count = 0 - while (fileInputStream.read(buffer).also { count = it } > 0) { - outputStream.write(buffer, 0, count) - } - - outputStream.flush() - success = true - } - } - } catch (e: IOException) { - SaveResultModel(false, null, e.toString()).toHashMap() - } finally { - outputStream?.close() - fileInputStream?.close() - } - return if (success) { - sendBroadcast(context, fileUri) - SaveResultModel(fileUri.toString().isNotEmpty(), fileUri.toString(), null).toHashMap() - } else { - SaveResultModel(false, null, "saveFileToGallery fail").toHashMap() - } - } -} - -class SaveResultModel(var isSuccess: Boolean, - var filePath: String? = null, - var errorMessage: String? = null) { - fun toHashMap(): HashMap { - val hashMap = HashMap() - hashMap["isSuccess"] = isSuccess - hashMap["filePath"] = filePath - hashMap["errorMessage"] = errorMessage - return hashMap - } -} diff --git a/plugins/image_gallery_saver/ios/.gitignore b/plugins/image_gallery_saver/ios/.gitignore deleted file mode 100644 index 710ec6cf1..000000000 --- a/plugins/image_gallery_saver/ios/.gitignore +++ /dev/null @@ -1,36 +0,0 @@ -.idea/ -.vagrant/ -.sconsign.dblite -.svn/ - -.DS_Store -*.swp -profile - -DerivedData/ -build/ -GeneratedPluginRegistrant.h -GeneratedPluginRegistrant.m - -.generated/ - -*.pbxuser -*.mode1v3 -*.mode2v3 -*.perspectivev3 - -!default.pbxuser -!default.mode1v3 -!default.mode2v3 -!default.perspectivev3 - -xcuserdata - -*.moved-aside - -*.pyc -*sync/ -Icon? -.tags* - -/Flutter/Generated.xcconfig diff --git a/plugins/image_gallery_saver/ios/Assets/.gitkeep b/plugins/image_gallery_saver/ios/Assets/.gitkeep deleted file mode 100644 index e69de29bb..000000000 diff --git a/plugins/image_gallery_saver/ios/Classes/ImageGallerySaverPlugin.h b/plugins/image_gallery_saver/ios/Classes/ImageGallerySaverPlugin.h deleted file mode 100644 index 39b96a02f..000000000 --- a/plugins/image_gallery_saver/ios/Classes/ImageGallerySaverPlugin.h +++ /dev/null @@ -1,4 +0,0 @@ -#import - -@interface ImageGallerySaverPlugin : NSObject -@end diff --git a/plugins/image_gallery_saver/ios/Classes/ImageGallerySaverPlugin.m b/plugins/image_gallery_saver/ios/Classes/ImageGallerySaverPlugin.m deleted file mode 100644 index a9e65e35d..000000000 --- a/plugins/image_gallery_saver/ios/Classes/ImageGallerySaverPlugin.m +++ /dev/null @@ -1,13 +0,0 @@ -#import "ImageGallerySaverPlugin.h" - -#if __has_include() -#import -#else -#import "image_gallery_saver-Swift.h" -#endif - -@implementation ImageGallerySaverPlugin -+ (void)registerWithRegistrar:(NSObject*)registrar { - [SwiftImageGallerySaverPlugin registerWithRegistrar:registrar]; -} -@end diff --git a/plugins/image_gallery_saver/ios/Classes/SwiftImageGallerySaverPlugin.swift b/plugins/image_gallery_saver/ios/Classes/SwiftImageGallerySaverPlugin.swift deleted file mode 100644 index 876c31873..000000000 --- a/plugins/image_gallery_saver/ios/Classes/SwiftImageGallerySaverPlugin.swift +++ /dev/null @@ -1,194 +0,0 @@ -import Flutter -import UIKit -import Photos - -public class SwiftImageGallerySaverPlugin: NSObject, FlutterPlugin { - let errorMessage = "Failed to save, please check whether the permission is enabled" - - var result: FlutterResult?; - - public static func register(with registrar: FlutterPluginRegistrar) { - let channel = FlutterMethodChannel(name: "image_gallery_saver", binaryMessenger: registrar.messenger()) - let instance = SwiftImageGallerySaverPlugin() - registrar.addMethodCallDelegate(instance, channel: channel) - } - - public func handle(_ call: FlutterMethodCall, result: @escaping FlutterResult) { - self.result = result - if call.method == "saveImageToGallery" { - let arguments = call.arguments as? [String: Any] ?? [String: Any]() - guard let imageData = (arguments["imageBytes"] as? FlutterStandardTypedData)?.data, - let image = UIImage(data: imageData), - let quality = arguments["quality"] as? Int, - let _ = arguments["name"], - let isReturnImagePath = arguments["isReturnImagePathOfIOS"] as? Bool - else { return } - let newImage = image.jpegData(compressionQuality: CGFloat(quality / 100))! - saveImage(UIImage(data: newImage) ?? image, isReturnImagePath: isReturnImagePath) - } else if (call.method == "saveFileToGallery") { - guard let arguments = call.arguments as? [String: Any], - let path = arguments["file"] as? String, - let _ = arguments["name"], - let isReturnFilePath = arguments["isReturnPathOfIOS"] as? Bool else { return } - if (isImageFile(filename: path)) { - saveImageAtFileUrl(path, isReturnImagePath: isReturnFilePath) - } else { - if (UIVideoAtPathIsCompatibleWithSavedPhotosAlbum(path)) { - saveVideo(path, isReturnImagePath: isReturnFilePath) - }else{ - self.saveResult(isSuccess:false,error:self.errorMessage) - } - } - } else { - result(FlutterMethodNotImplemented) - } - } - - func saveVideo(_ path: String, isReturnImagePath: Bool) { - if !isReturnImagePath { - UISaveVideoAtPathToSavedPhotosAlbum(path, self, #selector(didFinishSavingVideo(videoPath:error:contextInfo:)), nil) - return - } - var videoIds: [String] = [] - - PHPhotoLibrary.shared().performChanges( { - let req = PHAssetChangeRequest.creationRequestForAssetFromVideo(atFileURL: URL.init(fileURLWithPath: path)) - if let videoId = req?.placeholderForCreatedAsset?.localIdentifier { - videoIds.append(videoId) - } - }, completionHandler: { [unowned self] (success, error) in - DispatchQueue.main.async { - if (success && videoIds.count > 0) { - let assetResult = PHAsset.fetchAssets(withLocalIdentifiers: videoIds, options: nil) - if (assetResult.count > 0) { - let videoAsset = assetResult[0] - PHImageManager().requestAVAsset(forVideo: videoAsset, options: nil) { (avurlAsset, audioMix, info) in - if let urlStr = (avurlAsset as? AVURLAsset)?.url.absoluteString { - self.saveResult(isSuccess: true, filePath: urlStr) - } - } - } - } else { - self.saveResult(isSuccess: false, error: self.errorMessage) - } - } - }) - } - - func saveImage(_ image: UIImage, isReturnImagePath: Bool) { - if !isReturnImagePath { - UIImageWriteToSavedPhotosAlbum(image, self, #selector(didFinishSavingImage(image:error:contextInfo:)), nil) - return - } - - var imageIds: [String] = [] - - PHPhotoLibrary.shared().performChanges( { - let req = PHAssetChangeRequest.creationRequestForAsset(from: image) - if let imageId = req.placeholderForCreatedAsset?.localIdentifier { - imageIds.append(imageId) - } - }, completionHandler: { [unowned self] (success, error) in - DispatchQueue.main.async { - if (success && imageIds.count > 0) { - let assetResult = PHAsset.fetchAssets(withLocalIdentifiers: imageIds, options: nil) - if (assetResult.count > 0) { - let imageAsset = assetResult[0] - let options = PHContentEditingInputRequestOptions() - options.canHandleAdjustmentData = { (adjustmeta) - -> Bool in true } - imageAsset.requestContentEditingInput(with: options) { [unowned self] (contentEditingInput, info) in - if let urlStr = contentEditingInput?.fullSizeImageURL?.absoluteString { - self.saveResult(isSuccess: true, filePath: urlStr) - } - } - } - } else { - self.saveResult(isSuccess: false, error: self.errorMessage) - } - } - }) - } - - func saveImageAtFileUrl(_ url: String, isReturnImagePath: Bool) { - if !isReturnImagePath { - if let image = UIImage(contentsOfFile: url) { - UIImageWriteToSavedPhotosAlbum(image, self, #selector(didFinishSavingImage(image:error:contextInfo:)), nil) - } - return - } - - var imageIds: [String] = [] - - PHPhotoLibrary.shared().performChanges( { - let req = PHAssetChangeRequest.creationRequestForAssetFromImage(atFileURL: URL(string: url)!) - if let imageId = req?.placeholderForCreatedAsset?.localIdentifier { - imageIds.append(imageId) - } - }, completionHandler: { [unowned self] (success, error) in - DispatchQueue.main.async { - if (success && imageIds.count > 0) { - let assetResult = PHAsset.fetchAssets(withLocalIdentifiers: imageIds, options: nil) - if (assetResult.count > 0) { - let imageAsset = assetResult[0] - let options = PHContentEditingInputRequestOptions() - options.canHandleAdjustmentData = { (adjustmeta) - -> Bool in true } - imageAsset.requestContentEditingInput(with: options) { [unowned self] (contentEditingInput, info) in - if let urlStr = contentEditingInput?.fullSizeImageURL?.absoluteString { - self.saveResult(isSuccess: true, filePath: urlStr) - } - } - } - } else { - self.saveResult(isSuccess: false, error: self.errorMessage) - } - } - }) - } - - /// finish saving,if has error,parameters error will not nill - @objc func didFinishSavingImage(image: UIImage, error: NSError?, contextInfo: UnsafeMutableRawPointer?) { - saveResult(isSuccess: error == nil, error: error?.description) - } - - @objc func didFinishSavingVideo(videoPath: String, error: NSError?, contextInfo: UnsafeMutableRawPointer?) { - saveResult(isSuccess: error == nil, error: error?.description) - } - - func saveResult(isSuccess: Bool, error: String? = nil, filePath: String? = nil) { - var saveResult = SaveResultModel() - saveResult.isSuccess = error == nil - saveResult.errorMessage = error?.description - saveResult.filePath = filePath - result?(saveResult.toDic()) - } - - func isImageFile(filename: String) -> Bool { - return filename.hasSuffix(".jpg") - || filename.hasSuffix(".png") - || filename.hasSuffix(".jpeg") - || filename.hasSuffix(".JPEG") - || filename.hasSuffix(".JPG") - || filename.hasSuffix(".PNG") - || filename.hasSuffix(".gif") - || filename.hasSuffix(".GIF") - || filename.hasSuffix(".heic") - || filename.hasSuffix(".HEIC") - } -} - -public struct SaveResultModel: Encodable { - var isSuccess: Bool! - var filePath: String? - var errorMessage: String? - - func toDic() -> [String:Any]? { - let encoder = JSONEncoder() - guard let data = try? encoder.encode(self) else { return nil } - if (!JSONSerialization.isValidJSONObject(data)) { - return try? JSONSerialization.jsonObject(with: data, options: .mutableContainers) as? [String:Any] - } - return nil - } -} diff --git a/plugins/image_gallery_saver/ios/image_gallery_saver.podspec b/plugins/image_gallery_saver/ios/image_gallery_saver.podspec deleted file mode 100644 index e27e6d17b..000000000 --- a/plugins/image_gallery_saver/ios/image_gallery_saver.podspec +++ /dev/null @@ -1,23 +0,0 @@ -# -# To learn more about a Podspec see http://guides.cocoapods.org/syntax/podspec.html -# -Pod::Spec.new do |s| - s.name = 'image_gallery_saver' - s.version = '2.0.2' - s.summary = 'A new flutter plugin project.' - s.description = <<-DESC -A new flutter plugin project. - DESC - s.homepage = 'http://example.com' - s.license = { :file => '../LICENSE' } - s.author = { 'Your Company' => 'email@example.com' } - s.source = { :path => '.' } - s.source_files = 'Classes/**/*' - s.public_header_files = 'Classes/**/*.h' - s.dependency 'Flutter' - - s.ios.deployment_target = '8.0' - s.swift_version = '5.0' - s.pod_target_xcconfig = { 'DEFINES_MODULE' => 'YES', 'VALID_ARCHS[sdk=iphonesimulator*]' => 'x86_64' } -end - diff --git a/plugins/image_gallery_saver/lib/image_gallery_saver.dart b/plugins/image_gallery_saver/lib/image_gallery_saver.dart deleted file mode 100644 index ffb12f2de..000000000 --- a/plugins/image_gallery_saver/lib/image_gallery_saver.dart +++ /dev/null @@ -1,38 +0,0 @@ -import 'dart:async'; - -import 'package:flutter/services.dart'; - -class ImageGallerySaver { - static const MethodChannel _channel = - const MethodChannel('image_gallery_saver'); - - /// save image to Gallery - /// imageBytes can't null - /// return Map type - /// for example:{"isSuccess":true, "filePath":String?} - static FutureOr saveImage(Uint8List imageBytes, - {int quality = 80, - String? name, - bool isReturnImagePathOfIOS = false}) async { - final result = - await _channel.invokeMethod('saveImageToGallery', { - 'imageBytes': imageBytes, - 'quality': quality, - 'name': name, - 'isReturnImagePathOfIOS': isReturnImagePathOfIOS - }); - return result; - } - - /// Save the PNG,JPG,JPEG image or video located at [file] to the local device media gallery. - static Future saveFile(String file, - {String? name, bool isReturnPathOfIOS = false}) async { - final result = await _channel.invokeMethod( - 'saveFileToGallery', { - 'file': file, - 'name': name, - 'isReturnPathOfIOS': isReturnPathOfIOS - }); - return result; - } -} diff --git a/plugins/image_gallery_saver/pubspec.yaml b/plugins/image_gallery_saver/pubspec.yaml deleted file mode 100644 index 9ca158199..000000000 --- a/plugins/image_gallery_saver/pubspec.yaml +++ /dev/null @@ -1,31 +0,0 @@ -name: image_gallery_saver -description: A flutter plugin for save image to gallery, iOS need to add the following keys to your Info.plist file. -version: 2.0.3 -homepage: https://github.com/hui-z/image_gallery_saver - -environment: - sdk: '>=2.12.0 <4.0.0' - flutter: ">=1.17.0" - -dependencies: - flutter: - sdk: flutter - - -# For information on the generic Dart part of this file, see the -# following page: https://www.dartlang.org/tools/pub/pubspec - -# The following section is specific to Flutter. -flutter: - plugin: - platforms: - android: - package: com.example.imagegallerysaver - pluginClass: ImageGallerySaverPlugin - ios: - pluginClass: ImageGallerySaverPlugin - -dev_dependencies: - flutter_test: - sdk: flutter - diff --git a/pubspec.lock b/pubspec.lock index df59d05e7..b726a2213 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -725,13 +725,14 @@ packages: url: "https://pub.flutter-io.cn" source: hosted version: "7.0.0" - image_gallery_saver: + image_gallery_saver_plus: dependency: "direct main" description: - path: "plugins/image_gallery_saver" - relative: true - source: path - version: "2.0.3" + name: image_gallery_saver_plus + sha256: "199b9e24f8d85e98f11e3d35571ab68ae50626ad40e2bb85c84383f69a6950ad" + url: "https://pub.flutter-io.cn" + source: hosted + version: "4.0.1" image_picker: dependency: "direct main" description: @@ -1127,10 +1128,10 @@ packages: dependency: "direct main" description: name: permission_handler - sha256: "2d070d8684b68efb580a5997eb62f675e8a885ef0be6e754fb9ef489c177470f" + sha256: bc917da36261b00137bbc8896bf1482169cd76f866282368948f032c8c1caae1 url: "https://pub.flutter-io.cn" source: hosted - version: "12.0.0+1" + version: "12.0.1" permission_handler_android: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index c5b9b3a08..20f08b6d5 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -16,7 +16,7 @@ publish_to: 'none' # Remove this line if you wish to publish to pub.dev # Read more about iOS versioning at # https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html -version: 1.7.0+339 +version: 1.7.0+340 environment: sdk: ">=2.12.0 <3.0.0" @@ -53,7 +53,7 @@ dependencies: uuid: ^3.0.7 crypto: ^3.0.2 mime_type: ^1.0.0 - permission_handler: ^12.0.0+1 + permission_handler: ^12.0.1 vibration: ^3.1.3 flutter_local_notifications: ^18.0.1 fixnum: ^1.0.1 @@ -101,8 +101,7 @@ dependencies: # file path: ^1.8.2 file_picker: ^10.1.2 - image_gallery_saver: - path: ./plugins/image_gallery_saver + image_gallery_saver_plus: ^4.0.1 open_filex: ^4.7.0 # image From 921c8329c332fc59cd4272dd03588aa3f2d45858 Mon Sep 17 00:00:00 2001 From: Heron Date: Wed, 1 Oct 2025 11:03:37 +0800 Subject: [PATCH 22/47] Upgrade android project --- android/build.gradle.kts | 2 +- android/gradle/wrapper/gradle-wrapper.properties | 2 +- android/settings.gradle.kts | 2 +- golib/Makefile | 2 +- ios/Runner.xcodeproj/project.pbxproj | 16 ++++++++++++---- pubspec.yaml | 2 +- 6 files changed, 17 insertions(+), 9 deletions(-) diff --git a/android/build.gradle.kts b/android/build.gradle.kts index 41bcc6586..193772049 100644 --- a/android/build.gradle.kts +++ b/android/build.gradle.kts @@ -4,7 +4,7 @@ buildscript { mavenCentral() } dependencies { - classpath("com.android.tools.build:gradle:8.1.4") + classpath("com.android.tools.build:gradle:8.12.3") classpath("org.jetbrains.kotlin:kotlin-gradle-plugin:1.9.22") } } diff --git a/android/gradle/wrapper/gradle-wrapper.properties b/android/gradle/wrapper/gradle-wrapper.properties index efdcc4ace..02767eb1c 100644 --- a/android/gradle/wrapper/gradle-wrapper.properties +++ b/android/gradle/wrapper/gradle-wrapper.properties @@ -2,4 +2,4 @@ distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists zipStoreBase=GRADLE_USER_HOME zipStorePath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-8.11.1-all.zip +distributionUrl=https\://services.gradle.org/distributions/gradle-8.13-all.zip diff --git a/android/settings.gradle.kts b/android/settings.gradle.kts index b51a5bde6..0a775ffe7 100644 --- a/android/settings.gradle.kts +++ b/android/settings.gradle.kts @@ -18,7 +18,7 @@ pluginManagement { plugins { id("dev.flutter.flutter-plugin-loader") version "1.0.0" - id("com.android.application") version "8.9.1" apply false + id("com.android.application") version "8.12.3" apply false id("org.jetbrains.kotlin.android") version "2.0.0" apply false } diff --git a/golib/Makefile b/golib/Makefile index c19b1c22e..ead95bdb6 100644 --- a/golib/Makefile +++ b/golib/Makefile @@ -5,7 +5,7 @@ GOMOBILE=gomobile GOBIND=$(GOMOBILE) bind BUILDDIR=$(shell pwd)/build IMPORT_PATH=nkn -LDFLAGS='-s -w' +LDFLAGS="-s -w -extldflags=-Wl,-z,max-page-size=16384 -checklinkname=0" ANDROID_LDFLAGS='-s -w' ANDROID_BUILDDIR=$(BUILDDIR)/android diff --git a/ios/Runner.xcodeproj/project.pbxproj b/ios/Runner.xcodeproj/project.pbxproj index 465bf6fce..1732a36b6 100644 --- a/ios/Runner.xcodeproj/project.pbxproj +++ b/ios/Runner.xcodeproj/project.pbxproj @@ -3,7 +3,7 @@ archiveVersion = 1; classes = { }; - objectVersion = 54; + objectVersion = 77; objects = { /* Begin PBXBuildFile section */ @@ -509,10 +509,14 @@ inputFileListPaths = ( "${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-resources-${CONFIGURATION}-input-files.xcfilelist", ); + inputPaths = ( + ); name = "[CP] Copy Pods Resources"; outputFileListPaths = ( "${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-resources-${CONFIGURATION}-output-files.xcfilelist", ); + outputPaths = ( + ); runOnlyForDeploymentPostprocessing = 0; shellPath = /bin/sh; shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-resources.sh\"\n"; @@ -541,10 +545,14 @@ inputFileListPaths = ( "${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks-${CONFIGURATION}-input-files.xcfilelist", ); + inputPaths = ( + ); name = "[CP] Embed Pods Frameworks"; outputFileListPaths = ( "${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks-${CONFIGURATION}-output-files.xcfilelist", ); + outputPaths = ( + ); runOnlyForDeploymentPostprocessing = 0; shellPath = /bin/sh; shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks.sh\"\n"; @@ -837,7 +845,7 @@ CODE_SIGN_ENTITLEMENTS = Runner/Runner.entitlements; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 337; + CURRENT_PROJECT_VERSION = 340; DEVELOPMENT_TEAM = 67P82ZQDAS; ENABLE_BITCODE = NO; INFOPLIST_FILE = Runner/Info.plist; @@ -973,7 +981,7 @@ CODE_SIGN_ENTITLEMENTS = Runner/Runner.entitlements; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 337; + CURRENT_PROJECT_VERSION = 340; DEVELOPMENT_TEAM = 67P82ZQDAS; ENABLE_BITCODE = NO; INFOPLIST_FILE = Runner/Info.plist; @@ -1001,7 +1009,7 @@ CODE_SIGN_ENTITLEMENTS = Runner/Runner.entitlements; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 337; + CURRENT_PROJECT_VERSION = 340; DEVELOPMENT_TEAM = 67P82ZQDAS; ENABLE_BITCODE = NO; INFOPLIST_FILE = Runner/Info.plist; diff --git a/pubspec.yaml b/pubspec.yaml index 20f08b6d5..e45a38cb2 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -16,7 +16,7 @@ publish_to: 'none' # Remove this line if you wish to publish to pub.dev # Read more about iOS versioning at # https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html -version: 1.7.0+340 +version: 1.7.0+341 environment: sdk: ">=2.12.0 <3.0.0" From 69a9505b0cb7db841480e6f30b8c650070f9f04d Mon Sep 17 00:00:00 2001 From: Heron Date: Wed, 1 Oct 2025 12:46:35 +0800 Subject: [PATCH 23/47] fix: correct default wallet on upgrade --- android/gradle.properties | 3 +- ios/Runner.xcodeproj/project.pbxproj | 10 +--- lib/main.dart | 33 +++++++----- lib/upgrade.dart | 76 ++++++++++++++++++++++++++++ pubspec.yaml | 2 +- 5 files changed, 99 insertions(+), 25 deletions(-) create mode 100644 lib/upgrade.dart diff --git a/android/gradle.properties b/android/gradle.properties index e8e664173..24863d218 100644 --- a/android/gradle.properties +++ b/android/gradle.properties @@ -1,4 +1,3 @@ org.gradle.jvmargs=-Xmx8G -XX:MaxMetaspaceSize=4G -XX:ReservedCodeCacheSize=512m -XX:+HeapDumpOnOutOfMemoryError android.useAndroidX=true -android.enableJetifier=true -android.enableR8=true \ No newline at end of file +android.enableJetifier=true \ No newline at end of file diff --git a/ios/Runner.xcodeproj/project.pbxproj b/ios/Runner.xcodeproj/project.pbxproj index 1732a36b6..cdcf043a6 100644 --- a/ios/Runner.xcodeproj/project.pbxproj +++ b/ios/Runner.xcodeproj/project.pbxproj @@ -3,7 +3,7 @@ archiveVersion = 1; classes = { }; - objectVersion = 77; + objectVersion = 54; objects = { /* Begin PBXBuildFile section */ @@ -509,14 +509,10 @@ inputFileListPaths = ( "${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-resources-${CONFIGURATION}-input-files.xcfilelist", ); - inputPaths = ( - ); name = "[CP] Copy Pods Resources"; outputFileListPaths = ( "${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-resources-${CONFIGURATION}-output-files.xcfilelist", ); - outputPaths = ( - ); runOnlyForDeploymentPostprocessing = 0; shellPath = /bin/sh; shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-resources.sh\"\n"; @@ -545,14 +541,10 @@ inputFileListPaths = ( "${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks-${CONFIGURATION}-input-files.xcfilelist", ); - inputPaths = ( - ); name = "[CP] Embed Pods Frameworks"; outputFileListPaths = ( "${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks-${CONFIGURATION}-output-files.xcfilelist", ); - outputPaths = ( - ); runOnlyForDeploymentPostprocessing = 0; shellPath = /bin/sh; shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks.sh\"\n"; diff --git a/lib/main.dart b/lib/main.dart index 4fc5201cc..ae14fe30b 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -26,6 +26,8 @@ import 'package:nmobile/storages/wallet.dart'; import 'package:nmobile/utils/logger.dart'; import 'package:sentry_flutter/sentry_flutter.dart'; +import 'upgrade.dart'; + void main() async { WidgetsFlutterBinding.ensureInitialized(); @@ -54,26 +56,31 @@ void main() async { application.registerInitialize(() async { Routes.init(); await Settings.init(); + await Upgrade.run(); }); await application.initialize(); - // Auto create default wallet on first launch try { final createdFlag = await settings_storage.SettingsStorage.getSettings(settings_storage.SettingsStorage.DEFAULT_WALLET_CREATED); if (!(createdFlag == true || createdFlag?.toString() == 'true')) { - // create NKN wallet with empty password - final Wallet nkn = await Wallet.create(null, config: WalletConfig(password: '')); - if (nkn.address.isNotEmpty && nkn.keystore.isNotEmpty) { - final WalletStorage walletStorage = WalletStorage(); - final WalletSchema wallet = WalletSchema( - type: WalletType.nkn, - address: nkn.address, - publicKey: hexEncode(nkn.publicKey), - name: 'Default Account', - ); - await walletStorage.add(wallet, nkn.keystore, '', hexEncode(nkn.seed)); - await walletStorage.setDefaultAddress(wallet.address); + final WalletStorage walletStorage = WalletStorage(); + final List existingWallets = await walletStorage.getAll(); + if (existingWallets.isNotEmpty) { await settings_storage.SettingsStorage.setSettings(settings_storage.SettingsStorage.DEFAULT_WALLET_CREATED, true); + } else { + // create NKN wallet with empty password + final Wallet nkn = await Wallet.create(null, config: WalletConfig(password: '')); + if (nkn.address.isNotEmpty && nkn.keystore.isNotEmpty) { + final WalletSchema wallet = WalletSchema( + type: WalletType.nkn, + address: nkn.address, + publicKey: hexEncode(nkn.publicKey), + name: 'Default Account', + ); + await walletStorage.add(wallet, nkn.keystore, '', hexEncode(nkn.seed)); + await walletStorage.setDefaultAddress(wallet.address); + await settings_storage.SettingsStorage.setSettings(settings_storage.SettingsStorage.DEFAULT_WALLET_CREATED, true); + } } } } catch (e, st) { diff --git a/lib/upgrade.dart b/lib/upgrade.dart new file mode 100644 index 000000000..25e0646a3 --- /dev/null +++ b/lib/upgrade.dart @@ -0,0 +1,76 @@ +import 'package:nmobile/common/settings.dart'; +import 'package:nmobile/schema/wallet.dart'; +import 'package:nmobile/storages/settings.dart' as settings_storage; +import 'package:nmobile/storages/wallet.dart'; +import 'package:nmobile/utils/logger.dart'; + +/// Simple, single-function upgrade flow using if/else-if steps. +class Upgrade { + static const String _keyLastBuild = 'last_build'; + + /// Call once after Settings.init(). + /// Example: add steps like `if (needRunStep(342)) { /* do upgrade for 342 */ } else if (...) { ... }`. + static Future run() async { + final int prev = await _getPreviousBuild(); + final int curr = _parseBuild(Settings.build); + try { + // STEP: build 342 + if (needRunStep(prevBuild: prev, currBuild: curr, targetBuild: 342)) { + logger.i("Upgrade - Running build 342 upgrade step"); + logger.i("Upgrade - Previous build: $prev, Current build: $curr"); + final WalletStorage walletStorage = WalletStorage(); + final List wallets = await walletStorage.getAll(); + final String? defaultAddress = await walletStorage.getDefaultAddress(); + + if (wallets.length == 2) { + // If second is Default Account AND default is Default Account => set default to first + final WalletSchema second = wallets[1]; + final String secondName = (second.name ?? '').trim(); + final bool secondIsDefaultAccount = secondName == 'Default Account'; + final bool defaultIsSecond = (defaultAddress != null && defaultAddress.isNotEmpty && defaultAddress == second.address); + if (secondIsDefaultAccount && defaultIsSecond) { + await walletStorage.setDefaultAddress(wallets[0].address); + } + } else if (wallets.length > 2) { + // If default wallet is Default Account => clear default (do not delete wallet) + if (defaultAddress != null && defaultAddress.isNotEmpty) { + final int defIndex = wallets.indexWhere((w) => w.address == defaultAddress); + if (defIndex >= 0) { + final WalletSchema defWallet = wallets[defIndex]; + final String defName = (defWallet.name ?? '').trim(); + if (defName == 'Default Account') { + // Clear default wallet setting + await walletStorage.setDefaultAddress(null); + } + } + } + } + } + + } finally { + // Persist current build for next run + await settings_storage.SettingsStorage.setSettings(_keyLastBuild, Settings.build); + } + } + + /// Return true if this step should run for [targetBuild]: + /// - prevBuild is null/0 OR prevBuild < targetBuild + /// - AND currBuild >= targetBuild + static bool needRunStep({required int prevBuild, required int currBuild, required int targetBuild}) { + if (currBuild <= 0) return false; + final bool prevIsUnsetOrLower = (prevBuild == 0 || prevBuild < targetBuild); + return prevIsUnsetOrLower && (currBuild >= targetBuild); + } + + static Future _getPreviousBuild() async { + final String? val = await settings_storage.SettingsStorage.getSettings(_keyLastBuild); + return _parseBuild(val); + } + + static int _parseBuild(String? buildRaw) { + if (buildRaw == null) return 0; + final String digits = buildRaw.replaceAll(RegExp(r'[^0-9]'), ''); + if (digits.isEmpty) return 0; + return int.tryParse(digits) ?? 0; + } +} \ No newline at end of file diff --git a/pubspec.yaml b/pubspec.yaml index e45a38cb2..c5898d4d3 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -16,7 +16,7 @@ publish_to: 'none' # Remove this line if you wish to publish to pub.dev # Read more about iOS versioning at # https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html -version: 1.7.0+341 +version: 1.7.0+342 environment: sdk: ">=2.12.0 <3.0.0" From 6ce631520d26942ef03916c7989e7992e08b4955 Mon Sep 17 00:00:00 2001 From: Heron Date: Wed, 1 Oct 2025 13:00:30 +0800 Subject: [PATCH 24/47] Update version --- lib/upgrade.dart | 6 ++---- pubspec.yaml | 2 +- 2 files changed, 3 insertions(+), 5 deletions(-) diff --git a/lib/upgrade.dart b/lib/upgrade.dart index 25e0646a3..4835c30ad 100644 --- a/lib/upgrade.dart +++ b/lib/upgrade.dart @@ -9,14 +9,12 @@ class Upgrade { static const String _keyLastBuild = 'last_build'; /// Call once after Settings.init(). - /// Example: add steps like `if (needRunStep(342)) { /* do upgrade for 342 */ } else if (...) { ... }`. static Future run() async { final int prev = await _getPreviousBuild(); final int curr = _parseBuild(Settings.build); try { - // STEP: build 342 - if (needRunStep(prevBuild: prev, currBuild: curr, targetBuild: 342)) { - logger.i("Upgrade - Running build 342 upgrade step"); + if (needRunStep(prevBuild: prev, currBuild: curr, targetBuild: 343)) { + logger.i("Upgrade - Running build 343 upgrade step"); logger.i("Upgrade - Previous build: $prev, Current build: $curr"); final WalletStorage walletStorage = WalletStorage(); final List wallets = await walletStorage.getAll(); diff --git a/pubspec.yaml b/pubspec.yaml index c5898d4d3..1189a34ee 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -16,7 +16,7 @@ publish_to: 'none' # Remove this line if you wish to publish to pub.dev # Read more about iOS versioning at # https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html -version: 1.7.0+342 +version: 1.7.1+343 environment: sdk: ">=2.12.0 <3.0.0" From 834eac3f9c651aa1163d568b9a75aaddf5c6e0dc Mon Sep 17 00:00:00 2001 From: Heron Date: Mon, 13 Oct 2025 18:40:43 +0800 Subject: [PATCH 25/47] Hide error toast --- ios/Runner.xcodeproj/project.pbxproj | 16 ++++++++++++---- lib/helpers/error.dart | 6 ++++-- pubspec.yaml | 2 +- 3 files changed, 17 insertions(+), 7 deletions(-) diff --git a/ios/Runner.xcodeproj/project.pbxproj b/ios/Runner.xcodeproj/project.pbxproj index cdcf043a6..c07f48ec9 100644 --- a/ios/Runner.xcodeproj/project.pbxproj +++ b/ios/Runner.xcodeproj/project.pbxproj @@ -3,7 +3,7 @@ archiveVersion = 1; classes = { }; - objectVersion = 54; + objectVersion = 77; objects = { /* Begin PBXBuildFile section */ @@ -509,10 +509,14 @@ inputFileListPaths = ( "${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-resources-${CONFIGURATION}-input-files.xcfilelist", ); + inputPaths = ( + ); name = "[CP] Copy Pods Resources"; outputFileListPaths = ( "${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-resources-${CONFIGURATION}-output-files.xcfilelist", ); + outputPaths = ( + ); runOnlyForDeploymentPostprocessing = 0; shellPath = /bin/sh; shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-resources.sh\"\n"; @@ -541,10 +545,14 @@ inputFileListPaths = ( "${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks-${CONFIGURATION}-input-files.xcfilelist", ); + inputPaths = ( + ); name = "[CP] Embed Pods Frameworks"; outputFileListPaths = ( "${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks-${CONFIGURATION}-output-files.xcfilelist", ); + outputPaths = ( + ); runOnlyForDeploymentPostprocessing = 0; shellPath = /bin/sh; shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks.sh\"\n"; @@ -837,7 +845,7 @@ CODE_SIGN_ENTITLEMENTS = Runner/Runner.entitlements; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 340; + CURRENT_PROJECT_VERSION = 344; DEVELOPMENT_TEAM = 67P82ZQDAS; ENABLE_BITCODE = NO; INFOPLIST_FILE = Runner/Info.plist; @@ -973,7 +981,7 @@ CODE_SIGN_ENTITLEMENTS = Runner/Runner.entitlements; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 340; + CURRENT_PROJECT_VERSION = 344; DEVELOPMENT_TEAM = 67P82ZQDAS; ENABLE_BITCODE = NO; INFOPLIST_FILE = Runner/Info.plist; @@ -1001,7 +1009,7 @@ CODE_SIGN_ENTITLEMENTS = Runner/Runner.entitlements; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 340; + CURRENT_PROJECT_VERSION = 344; DEVELOPMENT_TEAM = 67P82ZQDAS; ENABLE_BITCODE = NO; INFOPLIST_FILE = Runner/Info.plist; diff --git a/lib/helpers/error.dart b/lib/helpers/error.dart index 4633acd37..565a4d6a3 100644 --- a/lib/helpers/error.dart +++ b/lib/helpers/error.dart @@ -141,8 +141,9 @@ class NknError { } } -void handleError(dynamic error, StackTrace? stackTrace, {bool toast = true, String? text, bool upload = true}) { +void handleError(dynamic error, StackTrace? stackTrace, {bool? toast, String? text, bool upload = true}) { if (Settings.isRelease) { + toast = toast ?? false; String errStr = error?.toString().toLowerCase() ?? ""; bool contains = _containsStrings(errStr, [ "wrong password", @@ -162,10 +163,11 @@ void handleError(dynamic error, StackTrace? stackTrace, {bool toast = true, Stri if (Settings.sentryEnable) Sentry.captureException(error, stackTrace: stackTrace); // await } } else if (Settings.debug) { + toast = toast ?? true; logger.e(error); debugPrintStack(maxFrames: 100); } - if (!toast) return; + if (toast != true) return; text = text ?? getErrorShow(error); if ((text != null) && text.isNotEmpty) { Toast.show(text); diff --git a/pubspec.yaml b/pubspec.yaml index 1189a34ee..03b9598d2 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -16,7 +16,7 @@ publish_to: 'none' # Remove this line if you wish to publish to pub.dev # Read more about iOS versioning at # https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html -version: 1.7.1+343 +version: 1.7.2+344 environment: sdk: ">=2.12.0 <3.0.0" From b6327fbc8ef238e20470c7ee4e739f30e4565522 Mon Sep 17 00:00:00 2001 From: Heron Date: Thu, 16 Oct 2025 18:01:53 +0800 Subject: [PATCH 26/47] Add dotenv --- .gitignore | 1 + ios/.gitignore | 2 ++ ios/Classes/Push2/APNSPusher.swift | 11 ++-------- ios/Flutter/Debug.xcconfig | 1 + ios/Flutter/Release.xcconfig | 1 + ios/Runner.xcodeproj/project.pbxproj | 32 ++++++++++++++++++++-------- lib/common/settings.dart | 12 ++++++++--- lib/main.dart | 3 +++ pubspec.lock | 8 +++++++ pubspec.yaml | 4 ++++ 10 files changed, 54 insertions(+), 21 deletions(-) diff --git a/.gitignore b/.gitignore index 5f84ec3cf..a53a795bf 100644 --- a/.gitignore +++ b/.gitignore @@ -58,3 +58,4 @@ nkn.aar **.p12 **.cer /android/app/.cxx/ +/.env diff --git a/ios/.gitignore b/ios/.gitignore index 151026b91..efa4cb9d3 100644 --- a/ios/.gitignore +++ b/ios/.gitignore @@ -31,3 +31,5 @@ Runner/GeneratedPluginRegistrant.* !default.mode2v3 !default.pbxuser !default.perspectivev3 +/Flutter/Secrets.xcconfig +/Runner/Config.generated.swift diff --git a/ios/Classes/Push2/APNSPusher.swift b/ios/Classes/Push2/APNSPusher.swift index 3473f8d76..7a3c93522 100644 --- a/ios/Classes/Push2/APNSPusher.swift +++ b/ios/Classes/Push2/APNSPusher.swift @@ -1,14 +1,7 @@ -// -// APNSPusher.swift -// Runner -// -// Created by 蒋治国 on 2021/10/31. -// - import Foundation -let p12FileName = "" -let p12FilePasswordd = "" +let p12FileName = BuildSecrets.apnsP12FileName +let p12FilePasswordd = BuildSecrets.apnsP12Password public class APNSPusher { diff --git a/ios/Flutter/Debug.xcconfig b/ios/Flutter/Debug.xcconfig index ec97fc6f3..d70aca916 100644 --- a/ios/Flutter/Debug.xcconfig +++ b/ios/Flutter/Debug.xcconfig @@ -1,2 +1,3 @@ #include? "Pods/Target Support Files/Pods-Runner/Pods-Runner.debug.xcconfig" #include "Generated.xcconfig" +#include? "Secrets.xcconfig" \ No newline at end of file diff --git a/ios/Flutter/Release.xcconfig b/ios/Flutter/Release.xcconfig index c4855bfe2..6f461927f 100644 --- a/ios/Flutter/Release.xcconfig +++ b/ios/Flutter/Release.xcconfig @@ -1,2 +1,3 @@ #include? "Pods/Target Support Files/Pods-Runner/Pods-Runner.release.xcconfig" #include "Generated.xcconfig" +#include? "Secrets.xcconfig" \ No newline at end of file diff --git a/ios/Runner.xcodeproj/project.pbxproj b/ios/Runner.xcodeproj/project.pbxproj index c07f48ec9..7870a259a 100644 --- a/ios/Runner.xcodeproj/project.pbxproj +++ b/ios/Runner.xcodeproj/project.pbxproj @@ -3,11 +3,12 @@ archiveVersion = 1; classes = { }; - objectVersion = 77; + objectVersion = 54; objects = { /* Begin PBXBuildFile section */ 147F890F2E83CBC000BFD48B /* ShareExtension.appex in Embed App Extensions */ = {isa = PBXBuildFile; fileRef = 147F89052E83CBC000BFD48B /* ShareExtension.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; }; + 147F899F2EA0F29300BFD48B /* Config.generated.swift in Sources */ = {isa = PBXBuildFile; fileRef = 147F899E2EA0F29300BFD48B /* Config.generated.swift */; }; 148FFB1A2E60499C005410D0 /* GoogleService-Info.plist in Resources */ = {isa = PBXBuildFile; fileRef = 148FFB192E60499C005410D0 /* GoogleService-Info.plist */; }; 1498D2341E8E89220040F4C2 /* GeneratedPluginRegistrant.m in Sources */ = {isa = PBXBuildFile; fileRef = 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */; }; 149BC80728FEAEB000D27A6D /* DnsResolver.swift in Sources */ = {isa = PBXBuildFile; fileRef = 149BC80628FEAEB000D27A6D /* DnsResolver.swift */; }; @@ -82,6 +83,7 @@ 0C98E54980A890E1BD057FA6 /* Pods-Runner.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.debug.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.debug.xcconfig"; sourceTree = ""; }; 142209AC2E7AB14500C679DC /* receive_sharing_intent.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; path = receive_sharing_intent.framework; sourceTree = BUILT_PRODUCTS_DIR; }; 147F89052E83CBC000BFD48B /* ShareExtension.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = ShareExtension.appex; sourceTree = BUILT_PRODUCTS_DIR; }; + 147F899E2EA0F29300BFD48B /* Config.generated.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Config.generated.swift; sourceTree = ""; }; 148FFB192E60499C005410D0 /* GoogleService-Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = "GoogleService-Info.plist"; sourceTree = ""; }; 1498D2321E8E86230040F4C2 /* GeneratedPluginRegistrant.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = GeneratedPluginRegistrant.h; sourceTree = ""; }; 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = GeneratedPluginRegistrant.m; sourceTree = ""; }; @@ -264,6 +266,7 @@ 97C146F01CF9000F007C117D /* Runner */ = { isa = PBXGroup; children = ( + 147F899E2EA0F29300BFD48B /* Config.generated.swift */, 148FFB192E60499C005410D0 /* GoogleService-Info.plist */, C6CABF45268452450054F007 /* Runner.entitlements */, 97C146FA1CF9000F007C117D /* Main.storyboard */, @@ -382,6 +385,7 @@ 9705A1C41CF9048500538489 /* Embed Frameworks */, E1B3F2B6291BA8040042F7B2 /* Embed App Extensions */, 9740EEB61CF901F6004384FC /* Run Script */, + 147F899D2EA0F0C900BFD48B /* ShellScript */, 97C146EA1CF9000F007C117D /* Sources */, 97C146EB1CF9000F007C117D /* Frameworks */, 97C146EC1CF9000F007C117D /* Resources */, @@ -463,6 +467,23 @@ /* End PBXResourcesBuildPhase section */ /* Begin PBXShellScriptBuildPhase section */ + 147F899D2EA0F0C900BFD48B /* ShellScript */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + ); + inputPaths = ( + ); + outputFileListPaths = ( + ); + outputPaths = ( + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "set -euo pipefail\n\nOUT_FILE=\"$SRCROOT/Runner/Config.generated.swift\"\n\n# 这些值来自 .xcconfig(或 Scheme/CI 环境变量)\n: \"${P12_FILE_NAME:=}\"\n: \"${P12_FILE_PASSWORD:=}\"\n\ncat > \"$OUT_FILE\" < Date: Sat, 1 Nov 2025 00:02:51 +0800 Subject: [PATCH 27/47] Implement search service with support for querying, user data submission, and client verification. Introduce Proof of Work (PoW) for secure data handling and enhance client management with unique identifiers. --- .../kotlin/org/nkn/mobile/app/MainActivity.kt | 2 + .../app/channels/impl/searchService/Search.kt | 379 ++++++++++++ android/build.gradle.kts | 8 +- android/settings.gradle.kts | 2 +- golib/Makefile | 6 +- golib/go.mod | 58 +- golib/go.sum | 131 ++-- golib/gomobile.go | 3 + golib/search/pow.go | 392 ++++++++++++ golib/search/search.go | 285 +++++++++ ios/Classes/impl/SearchService/Search.swift | 368 +++++++++++ ios/Podfile.lock | 2 +- ios/Runner.xcodeproj/project.pbxproj | 16 + ios/Runner/AppDelegate.swift | 1 + lib/common/search_service/search_service.dart | 132 ++++ lib/components/dialog/bottom.dart | 86 ++- lib/components/text/form_text.dart | 6 + lib/generated/intl/messages_en.dart | 22 + lib/generated/intl/messages_zh_CN.dart | 18 + lib/generated/intl/messages_zh_TW.dart | 18 + lib/generated/l10n.dart | 120 ++++ lib/l10n/intl_en.arb | 14 +- lib/l10n/intl_zh_CN.arb | 14 +- lib/l10n/intl_zh_TW.arb | 14 +- lib/native/search.dart | 569 ++++++++++++++++++ lib/providers/custom_id_provider.dart | 95 +++ lib/routes/contact.dart | 2 + lib/screens/contact/chat_profile.dart | 365 ++++++++--- lib/screens/contact/more_profile.dart | 103 ++++ lib/screens/contact/profile.dart | 197 ++++-- .../ios/nkn_sdk_flutter.podspec | 2 +- 31 files changed, 3227 insertions(+), 203 deletions(-) create mode 100644 android/app/src/main/kotlin/org/nkn/mobile/app/channels/impl/searchService/Search.kt create mode 100644 golib/search/pow.go create mode 100644 golib/search/search.go create mode 100644 ios/Classes/impl/SearchService/Search.swift create mode 100644 lib/common/search_service/search_service.dart create mode 100644 lib/native/search.dart create mode 100644 lib/providers/custom_id_provider.dart create mode 100644 lib/screens/contact/more_profile.dart diff --git a/android/app/src/main/kotlin/org/nkn/mobile/app/MainActivity.kt b/android/app/src/main/kotlin/org/nkn/mobile/app/MainActivity.kt index d97704ce2..5b152f2b4 100644 --- a/android/app/src/main/kotlin/org/nkn/mobile/app/MainActivity.kt +++ b/android/app/src/main/kotlin/org/nkn/mobile/app/MainActivity.kt @@ -10,6 +10,7 @@ import io.flutter.plugins.GeneratedPluginRegistrant import org.nkn.mobile.app.channels.impl.Common import org.nkn.mobile.app.channels.impl.nameService.DnsResolver import org.nkn.mobile.app.channels.impl.nameService.EthResolver +import org.nkn.mobile.app.channels.impl.searchService.SearchService import org.nkn.mobile.app.crypto.Crypto import org.nkn.mobile.app.push.APNSPush @@ -74,6 +75,7 @@ class MainActivity : FlutterFragmentActivity() { Crypto.register(flutterEngine) EthResolver.register(flutterEngine) DnsResolver.register(flutterEngine) + SearchService.register(flutterEngine) APNSPush.openClient(assets) } diff --git a/android/app/src/main/kotlin/org/nkn/mobile/app/channels/impl/searchService/Search.kt b/android/app/src/main/kotlin/org/nkn/mobile/app/channels/impl/searchService/Search.kt new file mode 100644 index 000000000..b162b2e4b --- /dev/null +++ b/android/app/src/main/kotlin/org/nkn/mobile/app/channels/impl/searchService/Search.kt @@ -0,0 +1,379 @@ +package org.nkn.mobile.app.channels.impl.searchService + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import io.flutter.embedding.engine.FlutterEngine +import io.flutter.plugin.common.BinaryMessenger +import io.flutter.plugin.common.EventChannel +import io.flutter.plugin.common.MethodCall +import io.flutter.plugin.common.MethodChannel +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import org.nkn.mobile.app.channels.IChannelHandler +import search.Search +import java.util.UUID +import java.util.concurrent.ConcurrentHashMap + +class SearchService : IChannelHandler, MethodChannel.MethodCallHandler, EventChannel.StreamHandler, ViewModel() { + + companion object { + lateinit var methodChannel: MethodChannel + const val METHOD_CHANNEL_NAME = "org.nkn.mobile/native/search" + + lateinit var eventChannel: EventChannel + const val EVENT_CHANNEL_NAME = "org.nkn.mobile/native/search_event" + private var eventSink: EventChannel.EventSink? = null + + // Store search client instances by ID + private val clients = ConcurrentHashMap() + + fun register(flutterEngine: FlutterEngine) { + SearchService().install(flutterEngine.dartExecutor.binaryMessenger) + } + } + + override fun install(binaryMessenger: BinaryMessenger) { + methodChannel = MethodChannel(binaryMessenger, METHOD_CHANNEL_NAME) + methodChannel.setMethodCallHandler(this) + eventChannel = EventChannel(binaryMessenger, EVENT_CHANNEL_NAME) + eventChannel.setStreamHandler(this) + } + + override fun uninstall() { + methodChannel.setMethodCallHandler(null) + eventChannel.setStreamHandler(null) + } + + override fun onListen(arguments: Any?, events: EventChannel.EventSink?) { + eventSink = events + } + + override fun onCancel(arguments: Any?) { + eventSink = null + } + + override fun onMethodCall(call: MethodCall, result: MethodChannel.Result) { + when (call.method) { + "newSearchClient" -> newSearchClient(call, result) + "newSearchClientWithAuth" -> newSearchClientWithAuth(call, result) + "query" -> query(call, result) + "submitUserData" -> submitUserData(call, result) + "verify" -> verify(call, result) + "queryByID" -> queryByID(call, result) + "getMyInfo" -> getMyInfo(call, result) + "getPublicKeyHex" -> getPublicKeyHex(call, result) + "getAddress" -> getAddress(call, result) + "isVerified" -> isVerified(call, result) + "disposeClient" -> disposeClient(call, result) + else -> result.notImplemented() + } + } + + // Create a query-only search client + private fun newSearchClient(call: MethodCall, result: MethodChannel.Result) { + val apiBase = call.argument("apiBase") ?: "" + + viewModelScope.launch(Dispatchers.IO) { + try { + val client = Search.newSearchClient(apiBase) + if (client == null) { + resultError(result, "CREATE_CLIENT_FAILED", "Failed to create search client") + return@launch + } + + // Generate unique ID for this client + val clientId = UUID.randomUUID().toString() + clients[clientId] = client + + val response = hashMapOf( + "clientId" to clientId + ) + + resultSuccess(result, response) + } catch (e: Exception) { + resultError(result, e) + } + } + } + + // Create an authenticated search client + private fun newSearchClientWithAuth(call: MethodCall, result: MethodChannel.Result) { + val apiBase = call.argument("apiBase") ?: "" + val seed = call.argument("seed") + + if (seed == null || seed.size != 32) { + viewModelScope.launch(Dispatchers.IO) { + resultError(result, "INVALID_SEED", "Seed must be exactly 32 bytes") + } + return + } + + viewModelScope.launch(Dispatchers.IO) { + try { + val client = Search.newSearchClientWithAuth(apiBase, seed) + if (client == null) { + resultError(result, "CREATE_AUTH_CLIENT_FAILED", "Failed to create authenticated search client") + return@launch + } + + // Generate unique ID for this client + val clientId = UUID.randomUUID().toString() + clients[clientId] = client + + val response = hashMapOf( + "clientId" to clientId + ) + + resultSuccess(result, response) + } catch (e: Exception) { + resultError(result, e) + } + } + } + + // Query data by keyword + private fun query(call: MethodCall, result: MethodChannel.Result) { + val clientId = call.argument("clientId") ?: "" + val keyword = call.argument("keyword") ?: "" + + val client = clients[clientId] + if (client == null) { + viewModelScope.launch(Dispatchers.IO) { + resultError(result, "CLIENT_NOT_FOUND", "Search client not found") + } + return + } + + viewModelScope.launch(Dispatchers.IO) { + try { + val response = client.query(keyword) + resultSuccess(result, response) + } catch (e: Exception) { + resultError(result, e) + } + } + } + + // Submit user data + private fun submitUserData(call: MethodCall, result: MethodChannel.Result) { + val clientId = call.argument("clientId") ?: "" + var nknAddress = call.argument("nknAddress") ?: "" + val customId = call.argument("customId") ?: "" + val nickname = call.argument("nickname") ?: "" + val phoneNumber = call.argument("phoneNumber") ?: "" + + val client = clients[clientId] + if (client == null) { + viewModelScope.launch(Dispatchers.Default) { + resultError(result, "CLIENT_NOT_FOUND", "Search client not found") + } + return + } + + // Use Dispatchers.Default for CPU-intensive PoW calculation + // Default dispatcher is optimized for CPU-bound work + viewModelScope.launch(Dispatchers.Default) { + try { + // Process nknAddress: if empty, use publicKey + val publicKeyHex = client.publicKeyHex ?: "" + + if (nknAddress.isEmpty()) { + nknAddress = publicKeyHex + } else { + // Validate format if contains dot + if (nknAddress.contains(".")) { + val parts = nknAddress.split(".") + if (parts.size != 2) { + resultError(result, "INVALID_PARAMETER", + "Invalid nknAddress format. Expected: identifier.publickey") + return@launch + } + val providedPubKey = parts[1] + if (providedPubKey.lowercase() != publicKeyHex.lowercase()) { + resultError(result, "INVALID_PARAMETER", + "nknAddress publickey suffix must match your actual publicKey") + return@launch + } + } else { + // If no dot, must equal publicKey + if (nknAddress.lowercase() != publicKeyHex.lowercase()) { + resultError(result, "INVALID_PARAMETER", + "nknAddress must be either \"identifier.publickey\" format or equal to publicKey") + return@launch + } + } + } + + // Validate customId if provided + if (customId.isNotEmpty() && customId.length < 3) { + resultError(result, "INVALID_PARAMETER", + "customId must be at least 3 characters if provided") + return@launch + } + + client.submitUserData(nknAddress, customId, nickname, phoneNumber) + val response = hashMapOf( + "success" to true + ) + resultSuccess(result, response) + } catch (e: Exception) { + resultError(result, e) + } + } + } + + // Verify the client (optional, for query operations) + private fun verify(call: MethodCall, result: MethodChannel.Result) { + val clientId = call.argument("clientId") ?: "" + + val client = clients[clientId] + if (client == null) { + viewModelScope.launch(Dispatchers.Default) { + resultError(result, "CLIENT_NOT_FOUND", "Search client not found") + } + return + } + + // Use Dispatchers.Default for CPU-intensive PoW calculation + viewModelScope.launch(Dispatchers.Default) { + try { + client.verify() + val response = hashMapOf( + "success" to true + ) + resultSuccess(result, response) + } catch (e: Exception) { + resultError(result, e) + } + } + } + + // Query by ID + private fun queryByID(call: MethodCall, result: MethodChannel.Result) { + val clientId = call.argument("clientId") ?: "" + val id = call.argument("id") ?: "" + + val client = clients[clientId] + if (client == null) { + viewModelScope.launch(Dispatchers.IO) { + resultError(result, "CLIENT_NOT_FOUND", "Search client not found") + } + return + } + + viewModelScope.launch(Dispatchers.IO) { + try { + val response = client.queryByID(id) + resultSuccess(result, response) + } catch (e: Exception) { + resultError(result, e) + } + } + } + + // Get my info by nknAddress + private fun getMyInfo(call: MethodCall, result: MethodChannel.Result) { + val clientId = call.argument("clientId") ?: "" + val address = call.argument("address") ?: "" + + val client = clients[clientId] + if (client == null) { + viewModelScope.launch(Dispatchers.IO) { + resultError(result, "CLIENT_NOT_FOUND", "Search client not found") + } + return + } + + viewModelScope.launch(Dispatchers.IO) { + try { + val response = client.getMyInfo(address) + resultSuccess(result, response) + } catch (e: Exception) { + resultError(result, e) + } + } + } + + // Get public key hex + private fun getPublicKeyHex(call: MethodCall, result: MethodChannel.Result) { + val clientId = call.argument("clientId") ?: "" + + val client = clients[clientId] + if (client == null) { + viewModelScope.launch(Dispatchers.IO) { + resultError(result, "CLIENT_NOT_FOUND", "Search client not found") + } + return + } + + viewModelScope.launch(Dispatchers.IO) { + try { + val publicKeyHex = client.publicKeyHex + resultSuccess(result, publicKeyHex) + } catch (e: Exception) { + resultError(result, e) + } + } + } + + // Get wallet address + private fun getAddress(call: MethodCall, result: MethodChannel.Result) { + val clientId = call.argument("clientId") ?: "" + + val client = clients[clientId] + if (client == null) { + viewModelScope.launch(Dispatchers.IO) { + resultError(result, "CLIENT_NOT_FOUND", "Search client not found") + } + return + } + + viewModelScope.launch(Dispatchers.IO) { + try { + val address = client.address + resultSuccess(result, address) + } catch (e: Exception) { + resultError(result, e) + } + } + } + + // Check if verified + private fun isVerified(call: MethodCall, result: MethodChannel.Result) { + val clientId = call.argument("clientId") ?: "" + + val client = clients[clientId] + if (client == null) { + viewModelScope.launch(Dispatchers.IO) { + resultError(result, "CLIENT_NOT_FOUND", "Search client not found") + } + return + } + + viewModelScope.launch(Dispatchers.IO) { + try { + val verified = client.isVerified + resultSuccess(result, verified) + } catch (e: Exception) { + resultError(result, e) + } + } + } + + // Dispose client + private fun disposeClient(call: MethodCall, result: MethodChannel.Result) { + val clientId = call.argument("clientId") ?: "" + + viewModelScope.launch(Dispatchers.IO) { + try { + clients.remove(clientId) + val response = hashMapOf( + "success" to true + ) + resultSuccess(result, response) + } catch (e: Exception) { + resultError(result, e) + } + } + } +} diff --git a/android/build.gradle.kts b/android/build.gradle.kts index 193772049..7f8edd7b9 100644 --- a/android/build.gradle.kts +++ b/android/build.gradle.kts @@ -5,7 +5,7 @@ buildscript { } dependencies { classpath("com.android.tools.build:gradle:8.12.3") - classpath("org.jetbrains.kotlin:kotlin-gradle-plugin:1.9.22") + classpath("org.jetbrains.kotlin:kotlin-gradle-plugin:2.2.21") } } @@ -48,8 +48,10 @@ subprojects { android.compileOptions.targetCompatibility = javaVersion tasks.withType(org.jetbrains.kotlin.gradle.tasks.KotlinCompile::class.java).configureEach { - kotlinOptions { - jvmTarget = javaVersion.toString() + compilerOptions { + jvmTarget.set(org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_21) + languageVersion.set(org.jetbrains.kotlin.gradle.dsl.KotlinVersion.KOTLIN_2_2) + apiVersion.set(org.jetbrains.kotlin.gradle.dsl.KotlinVersion.KOTLIN_2_2) } } diff --git a/android/settings.gradle.kts b/android/settings.gradle.kts index 0a775ffe7..b450fd7f9 100644 --- a/android/settings.gradle.kts +++ b/android/settings.gradle.kts @@ -19,7 +19,7 @@ pluginManagement { plugins { id("dev.flutter.flutter-plugin-loader") version "1.0.0" id("com.android.application") version "8.12.3" apply false - id("org.jetbrains.kotlin.android") version "2.0.0" apply false + id("org.jetbrains.kotlin.android") version "2.2.21" apply false } include(":app") diff --git a/golib/Makefile b/golib/Makefile index ead95bdb6..de1c09536 100644 --- a/golib/Makefile +++ b/golib/Makefile @@ -5,15 +5,15 @@ GOMOBILE=gomobile GOBIND=$(GOMOBILE) bind BUILDDIR=$(shell pwd)/build IMPORT_PATH=nkn -LDFLAGS="-s -w -extldflags=-Wl,-z,max-page-size=16384 -checklinkname=0" -ANDROID_LDFLAGS='-s -w' +LDFLAGS='-s -w -extldflags=-lresolv -checklinkname=0' +ANDROID_LDFLAGS='-s -w -extldflags=-Wl,-z,max-page-size=16384 -checklinkname=0' ANDROID_BUILDDIR=$(BUILDDIR)/android ANDROID_ARTIFACT=$(ANDROID_BUILDDIR)/nkn.aar IOS_BUILDDIR=$(BUILDDIR)/ios IOS_ARTIFACT=$(IOS_BUILDDIR)/Nkn.xcframework -BUILD_PACKAGE=./ ./crypto github.com/nknorg/nkn-sdk-go github.com/nknorg/ncp-go github.com/nknorg/nkn/v2/transaction github.com/nknorg/nkngomobile github.com/nknorg/reedsolomon github.com/nknorg/eth-resolver-go github.com/nknorg/dns-resolver-go +BUILD_PACKAGE=./ ./crypto ./search github.com/nknorg/nkn-sdk-go github.com/nknorg/ncp-go github.com/nknorg/nkn/v2/transaction github.com/nknorg/nkngomobile github.com/nknorg/reedsolomon github.com/nknorg/eth-resolver-go github.com/nknorg/dns-resolver-go ANDROID_BUILD_CMD="$(GOBIND) -ldflags $(ANDROID_LDFLAGS) -target=android -androidapi=21 -o $(ANDROID_ARTIFACT) $(BUILD_PACKAGE)" IOS_BUILD_CMD="$(GOBIND) -ldflags $(LDFLAGS) -target=ios -o $(IOS_ARTIFACT) $(BUILD_PACKAGE)" diff --git a/golib/go.mod b/golib/go.mod index 6a58d253e..e8cf9e540 100644 --- a/golib/go.mod +++ b/golib/go.mod @@ -1,15 +1,17 @@ module nkngolib -go 1.18 +go 1.22.0 + +toolchain go1.22.2 require ( - github.com/nknorg/dns-resolver-go v0.0.0-20220705102626-b041cd8d4a8e + github.com/nknorg/dns-resolver-go v0.0.0-20230404043755-e50d32d9043a github.com/nknorg/eth-resolver-go v0.0.0-20230404061427-d0bd773f899f - github.com/nknorg/ncp-go v1.0.5 // indirect - github.com/nknorg/nkn-sdk-go v1.4.6 - github.com/nknorg/nkn/v2 v2.2.0 + github.com/nknorg/nkn-sdk-go v1.4.9-0.20250718092920-5d1593ad7642 + github.com/nknorg/nkn/v2 v2.2.2-0.20250718093239-1e65fafdf8f0 github.com/nknorg/nkngomobile v0.0.0-20220615081414-671ad1afdfa9 github.com/nknorg/reedsolomon v1.9.12-0.20210315025804-a0c1b6031ab4 + golang.org/x/mobile v0.0.0-20240905004112-7c4916698cc9 ) require ( @@ -18,16 +20,16 @@ require ( github.com/ethereum/go-ethereum v1.10.15 // indirect github.com/go-ole/go-ole v1.2.6 // indirect github.com/go-stack/stack v1.8.1 // indirect - github.com/golang/protobuf v1.5.3 // indirect - github.com/google/uuid v1.3.0 // indirect - github.com/gorilla/websocket v1.5.0 // indirect + github.com/google/uuid v1.6.0 // indirect + github.com/gorilla/websocket v1.5.3 // indirect github.com/hashicorp/errwrap v1.1.0 // indirect github.com/hashicorp/go-multierror v1.1.1 // indirect github.com/imdario/mergo v0.3.15 // indirect - github.com/ipfs/go-cid v0.4.1 // indirect + github.com/ipfs/go-cid v0.4.0 // indirect github.com/itchyny/base58-go v0.2.1 // indirect github.com/jbenet/go-is-domain v1.0.5 // indirect - github.com/klauspost/cpuid/v2 v2.2.4 // indirect + github.com/jinzhu/copier v0.4.0 // indirect + github.com/klauspost/cpuid/v2 v2.2.8 // indirect github.com/minio/sha256-simd v1.0.0 // indirect github.com/mr-tron/base58 v1.2.0 // indirect github.com/multiformats/go-base32 v0.1.0 // indirect @@ -35,8 +37,26 @@ require ( github.com/multiformats/go-multibase v0.2.0 // indirect github.com/multiformats/go-multihash v0.2.1 // indirect github.com/multiformats/go-varint v0.0.7 // indirect + github.com/nknorg/ncp-go v1.0.7-0.20240928081416-1a805ec168d0 // indirect github.com/patrickmn/go-cache v2.1.0+incompatible // indirect github.com/pbnjay/memory v0.0.0-20210728143218-7b4eea64cf58 // indirect + github.com/pborman/uuid v1.2.1 // indirect + github.com/pion/datachannel v1.5.9 // indirect + github.com/pion/dtls/v3 v3.0.2 // indirect + github.com/pion/ice/v4 v4.0.1 // indirect + github.com/pion/interceptor v0.1.30 // indirect + github.com/pion/logging v0.2.2 // indirect + github.com/pion/mdns/v2 v2.0.7 // indirect + github.com/pion/randutil v0.1.0 // indirect + github.com/pion/rtcp v1.2.14 // indirect + github.com/pion/rtp v1.8.9 // indirect + github.com/pion/sctp v1.8.33 // indirect + github.com/pion/sdp/v3 v3.0.9 // indirect + github.com/pion/srtp/v3 v3.0.3 // indirect + github.com/pion/stun/v3 v3.0.0 // indirect + github.com/pion/transport/v3 v3.0.7 // indirect + github.com/pion/turn/v4 v4.0.0 // indirect + github.com/pion/webrtc/v4 v4.0.0-beta.30 // indirect github.com/pkg/errors v0.9.1 // indirect github.com/rjeczalik/notify v0.9.3 // indirect github.com/shirou/gopsutil v3.21.11+incompatible // indirect @@ -45,15 +65,17 @@ require ( github.com/tklauser/numcpus v0.6.0 // indirect github.com/wealdtech/go-ens/v3 v3.5.4 // indirect github.com/wealdtech/go-multicodec v1.4.0 // indirect + github.com/wlynxg/anet v0.0.4 // indirect github.com/yusufpapurcu/wmi v1.2.2 // indirect - golang.org/x/crypto v0.7.0 // indirect - golang.org/x/mobile v0.0.0-20230301163155-e0f57694e12c - golang.org/x/mod v0.8.0 // indirect - golang.org/x/net v0.8.0 // indirect - golang.org/x/sys v0.6.0 // indirect - golang.org/x/text v0.8.0 // indirect - golang.org/x/tools v0.6.0 // indirect - google.golang.org/protobuf v1.30.0 // indirect + golang.org/x/crypto v0.31.0 // indirect + golang.org/x/mod v0.20.0 // indirect + golang.org/x/net v0.33.0 // indirect + golang.org/x/sync v0.10.0 // indirect + golang.org/x/sys v0.28.0 // indirect + golang.org/x/text v0.21.0 // indirect + golang.org/x/time v0.6.0 // indirect + golang.org/x/tools v0.24.0 // indirect + google.golang.org/protobuf v1.34.2 // indirect gopkg.in/natefinch/npipe.v2 v2.0.0-20160621034901-c1b8fa8bdcce // indirect lukechampine.com/blake3 v1.1.7 // indirect ) diff --git a/golib/go.sum b/golib/go.sum index b084a2460..0d57ddf49 100644 --- a/golib/go.sum +++ b/golib/go.sum @@ -166,13 +166,10 @@ github.com/golang/protobuf v1.4.0-rc.1.0.20200221234624-67d41d38c208/go.mod h1:x github.com/golang/protobuf v1.4.0-rc.2/go.mod h1:LlEzMj4AhA7rCAGe4KMBDvJI+AwstrUpVNzEA03Pprs= github.com/golang/protobuf v1.4.0-rc.4.0.20200313231945-b860323f09d0/go.mod h1:WU3c8KckQ9AFe+yFwt9sWVRKCVIyN9cPHBJSNnbL67w= github.com/golang/protobuf v1.4.0/go.mod h1:jodUvKwWbYaEsadDk5Fwe5c77LiNKVO9IDvqG2KuDX0= -github.com/golang/protobuf v1.4.1/go.mod h1:U8fpvMrcmy5pZrNK1lt4xCsGvpyWQ/VVv6QDs8UjoX8= github.com/golang/protobuf v1.4.2/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI= github.com/golang/protobuf v1.4.3/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI= github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk= github.com/golang/protobuf v1.5.2/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY= -github.com/golang/protobuf v1.5.3 h1:KhyjKVUg7Usr/dYsdSqoFveMYd5ko72D+zANwlG1mmg= -github.com/golang/protobuf v1.5.3/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY= github.com/golang/snappy v0.0.0-20180518054509-2e65f85255db/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q= github.com/golang/snappy v0.0.1/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q= github.com/golang/snappy v0.0.3/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q= @@ -188,24 +185,27 @@ github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMyw github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/google/go-cmp v0.4.1/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/google/go-cmp v0.5.4/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= -github.com/google/go-cmp v0.5.5 h1:Khx7svrCpmxxtHBq5j2mp/xVjsi8hQMfNLvJFAlrGgU= github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= +github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= github.com/google/gofuzz v1.1.1-0.20200604201612-c04b05f3adfa/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= github.com/google/martian v2.1.0+incompatible/go.mod h1:9I4somxYTbIHy5NJKHRl3wXiIaQGbYVAs8BPL6v8lEs= github.com/google/pprof v0.0.0-20181206194817-3ea8567a2e57/go.mod h1:zfwlbNMJ+OItoe0UupaVj+oy1omPYYDuagoSzA8v9mc= github.com/google/pprof v0.0.0-20190515194954-54271f7e092f/go.mod h1:zfwlbNMJ+OItoe0UupaVj+oy1omPYYDuagoSzA8v9mc= github.com/google/pprof v0.0.0-20191218002539-d4f498aebedc/go.mod h1:ZgVRPoUq/hfqzAqh7sHMqb3I9Rq5C59dIz2SbBwJ4eM= github.com/google/renameio v0.1.0/go.mod h1:KWCgfxg9yswjAJkECMjeO8J8rahYeXnNhOm40UhjYkI= +github.com/google/uuid v1.0.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/google/uuid v1.1.5/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= -github.com/google/uuid v1.3.0 h1:t6JiXgmwXMjEs8VusXIJk2BXHsn+wx8BZdTaoZ5fu7I= github.com/google/uuid v1.3.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/googleapis/gax-go/v2 v2.0.4/go.mod h1:0Wqv26UfaUD9n4G6kQubkQ+KchISgw+vpHVxEJEs9eg= github.com/googleapis/gax-go/v2 v2.0.5/go.mod h1:DWXyrwAJ9X0FpwwEdw+IPEYBICEFu5mhpdKc/us6bOk= github.com/gopherjs/gopherjs v0.0.0-20181017120253-0766667cb4d1/go.mod h1:wJfORRmW1u3UXTncJ5qlYoELFm8eSnnEO6hX4iZ3EWY= github.com/gorilla/mux v1.8.0/go.mod h1:DVbg23sWSpFRCP0SfiEN6jmj59UnW/n46BH5rLB71So= github.com/gorilla/websocket v1.4.2/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= -github.com/gorilla/websocket v1.5.0 h1:PPwGk2jz7EePpoHN/+ClbZu8SPxiqlu12wZP/3sWmnc= -github.com/gorilla/websocket v1.5.0/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= +github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg= +github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= github.com/graph-gophers/graphql-go v0.0.0-20201113091052-beb923fada29/go.mod h1:9CQHMSxwO4MprSdzoIEobiHpoLtHm77vfxsvsIN5Vuc= github.com/hashicorp/errwrap v1.0.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4= github.com/hashicorp/errwrap v1.1.0 h1:OxrOeh75EUXMY8TBjag2fzXGZ40LB6IKw45YeGUDY2I= @@ -222,12 +222,13 @@ github.com/holiman/bloomfilter/v2 v2.0.3 h1:73e0e/V0tCydx14a0SCYS/EWCxgwLZ18CZcZ github.com/holiman/bloomfilter/v2 v2.0.3/go.mod h1:zpoh+gs7qcpqrHr3dB55AMiJwo0iURXE7ZOP9L9hSkA= github.com/holiman/uint256 v1.2.0/go.mod h1:y4ga/t+u+Xwd7CpDgZESaRcWy0I7XMlTMA25ApIH5Jw= github.com/holiman/uint256 v1.2.2 h1:TXKcSGc2WaxPD2+bmzAsVthL4+pEN0YwXcL5qED83vk= +github.com/holiman/uint256 v1.2.2/go.mod h1:SC8Ryt4n+UBbPbIBKaG9zbbDlp4jOru9xFZmPzLUTxw= github.com/hpcloud/tail v1.0.0/go.mod h1:ab1qPbhIpdTxEkNHXyeSf5vhxWSCs/tWer42PpOxQnU= -github.com/huin/goupnp v1.0.2 h1:RfGLP+h3mvisuWEyybxNq5Eft3NWhHLPeUN72kpKZoI= github.com/huin/goupnp v1.0.2/go.mod h1:0dxJBVBHqTMjIUMkESDTNgOOx/Mw5wYIfyFmdzSamkM= +github.com/huin/goupnp v1.3.0 h1:UvLUlWDNpoUdYzb2TCn+MuTWtcjXKSza2n6CBdQ0xXc= +github.com/huin/goupnp v1.3.0/go.mod h1:gnGPsThkYa7bFi/KWmEysQRf48l2dvR5bxr2OFckNX8= github.com/huin/goutil v0.0.0-20170803182201-1ca381bf3150/go.mod h1:PpLOETDnJ0o3iZrZfqZzyLl6l7F3c6L1oWn7OICBi6o= github.com/ianlancetaylor/demangle v0.0.0-20181102032728-5e5cf60278f6/go.mod h1:aSSvb/t6k1mPoxDqO4vJh6VOCGPwU4O0C2/Eqndh1Sc= -github.com/imdario/mergo v0.3.8/go.mod h1:2EnlNZ0deacrJVfApfmtdGgDfMuh/nq6Ok1EcJh5FfA= github.com/imdario/mergo v0.3.15 h1:M8XP7IuFNsqUx6VPK2P9OSmsYsI/YFaGil0uD21V3dM= github.com/imdario/mergo v0.3.15/go.mod h1:WBLT9ZmE3lPoWsEzCh9LPo3TiwVN+ZKEjmz+hD27ysY= github.com/inconshreveable/mousetrap v1.0.0/go.mod h1:PxqpIevigyE2G7u3NXJIT2ANytuPF1OarO4DADm73n8= @@ -243,8 +244,8 @@ github.com/influxdata/roaring v0.4.13-0.20180809181101-fc520f41fab6/go.mod h1:bS github.com/influxdata/tdigest v0.0.0-20181121200506-bf2b5ad3c0a9/go.mod h1:Js0mqiSBE6Ffsg94weZZ2c+v/ciT8QRHFOap7EKDrR0= github.com/influxdata/usage-client v0.0.0-20160829180054-6d3895376368/go.mod h1:Wbbw6tYNvwa5dlB6304Sd+82Z3f7PmVZHVKU637d4po= github.com/ipfs/go-cid v0.1.0/go.mod h1:rH5/Xv83Rfy8Rw6xG+id3DYAMUVmem1MowoKwdXmN2o= -github.com/ipfs/go-cid v0.4.1 h1:A/T3qGvxi4kpKWWcPC/PgbvDA2bjVLO7n4UeVwnbs/s= -github.com/ipfs/go-cid v0.4.1/go.mod h1:uQHwDeX4c6CtyrFwdqyhpNcxVewur1M7l7fNU7LKwZk= +github.com/ipfs/go-cid v0.4.0 h1:a4pdZq0sx6ZSxbCizebnKiMCx/xI/aBBFlB73IgH4rA= +github.com/ipfs/go-cid v0.4.0/go.mod h1:uQHwDeX4c6CtyrFwdqyhpNcxVewur1M7l7fNU7LKwZk= github.com/itchyny/base58-go v0.2.1 h1:wtnhAVdOcW3WuHEASmGHMms4juOB8yEpj/KJxlB57+k= github.com/itchyny/base58-go v0.2.1/go.mod h1:BNvrKeAtWNSca1GohNbyhfff9/v0IrZjzWCAGeAvZZE= github.com/jackpal/go-nat-pmp v1.0.2-0.20160603034137-1fa385a6f458/go.mod h1:QPH045xvCAeXUZOxsnwmrtiCoxIr9eob+4orBN1SBKc= @@ -255,6 +256,8 @@ github.com/jbenet/go-is-domain v1.0.5/go.mod h1:xbRLRb0S7FgzDBTJlguhDVwLYM/5yNtv github.com/jedisct1/go-minisign v0.0.0-20190909160543-45766022959e/go.mod h1:G1CVv03EnqU1wYL2dFwXxW2An0az9JTl/ZsqXQeBlkU= github.com/jessevdk/go-flags v0.0.0-20141203071132-1679536dcc89/go.mod h1:4FA24M0QyGHXBuZZK/XkWh8h0e1EYbRYJSGM75WSRxI= github.com/jessevdk/go-flags v1.4.0/go.mod h1:4FA24M0QyGHXBuZZK/XkWh8h0e1EYbRYJSGM75WSRxI= +github.com/jinzhu/copier v0.4.0 h1:w3ciUoD19shMCRargcpm0cm91ytaBhDvuRpz1ODO/U8= +github.com/jinzhu/copier v0.4.0/go.mod h1:DfbEm0FYsaqBcKcFuvmOZb218JkPGtvSHsKg8S8hyyg= github.com/jmespath/go-jmespath v0.4.0/go.mod h1:T8mJZnbsbmF+m6zOOFylbeCJqk5+pHWvzYPziyZiYoo= github.com/jmespath/go-jmespath/internal/testify v1.5.1/go.mod h1:L3OGu8Wl2/fWfCI6z80xFu9LTZmf1ZRjMHUOPmWr69U= github.com/jrick/logrotate v1.0.0/go.mod h1:LNinyqDIJnpAur+b8yyulnQw/wDuN1+BYKlTRt3OuAQ= @@ -276,8 +279,8 @@ github.com/klauspost/cpuid/v2 v2.0.2/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa02 github.com/klauspost/cpuid/v2 v2.0.4/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg= github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg= github.com/klauspost/cpuid/v2 v2.0.11/go.mod h1:g2LTdtYhdyuGPqyWyv7qRAmj1WBqxuObKfj5c0PQa7c= -github.com/klauspost/cpuid/v2 v2.2.4 h1:acbojRNwl3o09bUq+yDCtZFc1aiwaAAxtcn8YkZXnvk= -github.com/klauspost/cpuid/v2 v2.2.4/go.mod h1:RVVoqg1df56z8g3pUjL/3lE5UfnlrJX8tyFgg4nqhuY= +github.com/klauspost/cpuid/v2 v2.2.8 h1:+StwCXwm9PdpiEkPyzBXIy+M9KUb4ODm0Zarf1kS5BM= +github.com/klauspost/cpuid/v2 v2.2.8/go.mod h1:Lcz8mBdAVJIBVzewtcLocK12l3Y+JytZYpaMropDUws= github.com/klauspost/crc32 v0.0.0-20161016154125-cb6bfca970f6/go.mod h1:+ZoRqAPRLkC4NPOvfYeR5KNOrY6TD+/sAC3HXPZgDYg= github.com/klauspost/pgzip v1.0.2-0.20170402124221-0bf5dcad4ada/go.mod h1:Ch1tH69qFZu15pkjo5kYi6mth2Zzwzt50oCQKQE9RUs= github.com/konsorten/go-windows-terminal-sequences v1.0.1/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ= @@ -305,8 +308,9 @@ github.com/mattn/go-ieproxy v0.0.0-20190702010315-6dee0af9227d/go.mod h1:31jz6HN github.com/mattn/go-isatty v0.0.4/go.mod h1:M+lRXTBqGeGNdLjl/ufCoiOlB5xdOkqRJdNxMWT7Zi4= github.com/mattn/go-isatty v0.0.8/go.mod h1:Iq45c/XA43vh69/j3iqttzPXn0bhXyGjM0Hdxcsrc5s= github.com/mattn/go-isatty v0.0.9/go.mod h1:YNRxwqDuOph6SZLI9vUUz6OYw3QyUt7WiY2yME+cCiQ= -github.com/mattn/go-isatty v0.0.12 h1:wuysRhFDzyxgEmMf5xjvJ2M9dZoWAXNNr5LSBS7uHXY= github.com/mattn/go-isatty v0.0.12/go.mod h1:cbi8OIDigv2wuxKPP5vlRcQ1OAZbq2CE4Kysco4FUpU= +github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= github.com/mattn/go-runewidth v0.0.3/go.mod h1:LwmH8dsx7+W8Uxz3IHJYH5QSwggIsqBzpuz5H//U1FU= github.com/mattn/go-runewidth v0.0.9/go.mod h1:H031xJmbD/WCDINGzjvQ9THkh0rPKHF+m2gUSrubnMI= github.com/mattn/go-runewidth v0.0.13 h1:lTGmDsbAYt5DmK6OnoV7EuIF1wEIFAcxld6ypU4OSgU= @@ -347,17 +351,17 @@ github.com/multiformats/go-varint v0.0.7/go.mod h1:r8PUYw/fD/SjBCiKOoDlGF6QawOEL github.com/mwitkow/go-conntrack v0.0.0-20161129095857-cc309e4a2223/go.mod h1:qRWi+5nqEBWmkhHvq77mSJWrCKwh8bxhgT7d/eI7P4U= github.com/naoina/go-stringutil v0.1.0/go.mod h1:XJ2SJL9jCtBh+P9q5btrd/Ylo8XwT/h1USek5+NqSA0= github.com/naoina/toml v0.1.2-0.20170918210437-9fafd6967416/go.mod h1:NBIhNtsFMo3G2szEBne+bO4gS192HuIYRqfvOWb4i1E= -github.com/nknorg/dns-resolver-go v0.0.0-20220705102626-b041cd8d4a8e h1:GBCXdZ7X3+je1Kz8eq34Y61knTGDkLJqqZEOi87nO7w= -github.com/nknorg/dns-resolver-go v0.0.0-20220705102626-b041cd8d4a8e/go.mod h1:4kmAlcljv8giDwPUs2SoGNB+KKicismVnOrBy8SgEi8= +github.com/nknorg/dns-resolver-go v0.0.0-20230404043755-e50d32d9043a h1:aZW6WMDsTZ39bmVNgJq0z5IsQkF3EWrjW6OXmrl03cE= +github.com/nknorg/dns-resolver-go v0.0.0-20230404043755-e50d32d9043a/go.mod h1:4kmAlcljv8giDwPUs2SoGNB+KKicismVnOrBy8SgEi8= github.com/nknorg/eth-resolver-go v0.0.0-20230404061427-d0bd773f899f h1:EOCImWeST/oFGiNCjX5ciUHmiQaK+idbCNS0eSojrYk= github.com/nknorg/eth-resolver-go v0.0.0-20230404061427-d0bd773f899f/go.mod h1:u5Dhckhgl266RlajmjBqvWHt66br0PG5MMg+dunrD1U= github.com/nknorg/mockconn-go v0.0.0-20230125231524-d664e728352a/go.mod h1:/SvBORYxt9wlm8ZbaEFEri6ooOSDcU3ovU0L2eRRdS4= -github.com/nknorg/ncp-go v1.0.5 h1:alJjq6bi6tRwUAAv932FIfE/R3S7DRR0pgXOgBXNHAk= -github.com/nknorg/ncp-go v1.0.5/go.mod h1:ze88qf5e9/DBXSOaJPL2Caa0IbdZJLzESAFM1S7mGwg= -github.com/nknorg/nkn-sdk-go v1.4.6 h1:GfvOAoC9Lj7WnZrPaO8UC62ieqAeHqNOMrePywsWKQE= -github.com/nknorg/nkn-sdk-go v1.4.6/go.mod h1:d4+iy0NmckVSgTUHUTloN5X5zcfph86126ubc1Rq9Lg= -github.com/nknorg/nkn/v2 v2.2.0 h1:sXOawvVF/T3bBTuWbzBCyrGuxldA3be+f+BDjoWcOEA= -github.com/nknorg/nkn/v2 v2.2.0/go.mod h1:yv3jkg0aOtN9BDHS4yerNSZJtJNBfGvlaD5K6wL6U3E= +github.com/nknorg/ncp-go v1.0.7-0.20240928081416-1a805ec168d0 h1:x19e5JSW9BpO4glxrOYQnfyoRQEUGQKIkg5XQAV0W1Y= +github.com/nknorg/ncp-go v1.0.7-0.20240928081416-1a805ec168d0/go.mod h1:y7WJ8zna/EsQ0Hifp8iLFuDRNQfPmDfALapsDfZoHEM= +github.com/nknorg/nkn-sdk-go v1.4.9-0.20250718092920-5d1593ad7642 h1:9IE50YvWLj8juzBoOt87oG2lKNGFGaq/Ron9P0CfbsQ= +github.com/nknorg/nkn-sdk-go v1.4.9-0.20250718092920-5d1593ad7642/go.mod h1:CyQJd95NT0UxiF/MDZ3843B5xgKwQCuPdU7wZGRL8Jc= +github.com/nknorg/nkn/v2 v2.2.2-0.20250718093239-1e65fafdf8f0 h1:y64x9g1QU6jrvLezQ/D6fB8KeHwPqgUa7GYUAgg8jjI= +github.com/nknorg/nkn/v2 v2.2.2-0.20250718093239-1e65fafdf8f0/go.mod h1:kmV1K5ZNspfubj6KkqfVSw+46uVINUiZ6EWHufg2CwE= github.com/nknorg/nkngomobile v0.0.0-20220615081414-671ad1afdfa9 h1:Gr37j7Ttvcn8g7TdC5fs6Y6IJKdmfqCvj03UbsrS77o= github.com/nknorg/nkngomobile v0.0.0-20220615081414-671ad1afdfa9/go.mod h1:zNY9NCyBcJCCDrXhwOjKarkW5cngPs/Z82xVNy/wvEA= github.com/nknorg/reedsolomon v1.9.12-0.20210315025804-a0c1b6031ab4 h1:Ug3GGTsny4ZcPsDeqOqoiBMts/yA8PBX1TJmMOeyc9Q= @@ -382,10 +386,44 @@ github.com/patrickmn/go-cache v2.1.0+incompatible/go.mod h1:3Qf8kWWT7OJRJbdiICTK github.com/paulbellamy/ratecounter v0.2.0/go.mod h1:Hfx1hDpSGoqxkVVpBi/IlYD7kChlfo5C6hzIHwPqfFE= github.com/pbnjay/memory v0.0.0-20210728143218-7b4eea64cf58 h1:onHthvaw9LFnH4t2DcNVpwGmV9E1BkGknEliJkfwQj0= github.com/pbnjay/memory v0.0.0-20210728143218-7b4eea64cf58/go.mod h1:DXv8WO4yhMYhSNPKjeNKa5WY9YCIEBRbNzFFPJbWO6Y= +github.com/pborman/uuid v1.2.1 h1:+ZZIw58t/ozdjRaXh/3awHfmWRbzYxJoAdNJxe/3pvw= +github.com/pborman/uuid v1.2.1/go.mod h1:X/NO0urCmaxf9VXbdlT7C2Yzkj2IKimNn4k+gtPdI/k= github.com/peterh/liner v1.0.1-0.20180619022028-8c1271fcf47f/go.mod h1:xIteQHvHuaLYG9IFj6mSxM0fCKrs34IrEQUhOYuGPHc= github.com/peterh/liner v1.1.1-0.20190123174540-a2c9a5303de7/go.mod h1:CRroGNssyjTd/qIG2FyxByd2S8JEAZXBl4qUrZf8GS0= github.com/philhofer/fwd v1.0.0/go.mod h1:gk3iGcWd9+svBvR0sR+KPcfE+RNWozjowpeBVG3ZVNU= github.com/pierrec/lz4 v2.0.5+incompatible/go.mod h1:pdkljMzZIN41W+lC3N2tnIh5sFi+IEE17M5jbnwPHcY= +github.com/pion/datachannel v1.5.9 h1:LpIWAOYPyDrXtU+BW7X0Yt/vGtYxtXQ8ql7dFfYUVZA= +github.com/pion/datachannel v1.5.9/go.mod h1:kDUuk4CU4Uxp82NH4LQZbISULkX/HtzKa4P7ldf9izE= +github.com/pion/dtls/v3 v3.0.2 h1:425DEeJ/jfuTTghhUDW0GtYZYIwwMtnKKJNMcWccTX0= +github.com/pion/dtls/v3 v3.0.2/go.mod h1:dfIXcFkKoujDQ+jtd8M6RgqKK3DuaUilm3YatAbGp5k= +github.com/pion/ice/v4 v4.0.1 h1:2d3tPoTR90F3TcGYeXUwucGlXI3hds96cwv4kjZmb9s= +github.com/pion/ice/v4 v4.0.1/go.mod h1:2dpakjpd7+74L5j3TAe6gvkbI5UIzOgAnkimm9SuHvA= +github.com/pion/interceptor v0.1.30 h1:au5rlVHsgmxNi+v/mjOPazbW1SHzfx7/hYOEYQnUcxA= +github.com/pion/interceptor v0.1.30/go.mod h1:RQuKT5HTdkP2Fi0cuOS5G5WNymTjzXaGF75J4k7z2nc= +github.com/pion/logging v0.2.2 h1:M9+AIj/+pxNsDfAT64+MAVgJO0rsyLnoJKCqf//DoeY= +github.com/pion/logging v0.2.2/go.mod h1:k0/tDVsRCX2Mb2ZEmTqNa7CWsQPc+YYCB7Q+5pahoms= +github.com/pion/mdns/v2 v2.0.7 h1:c9kM8ewCgjslaAmicYMFQIde2H9/lrZpjBkN8VwoVtM= +github.com/pion/mdns/v2 v2.0.7/go.mod h1:vAdSYNAT0Jy3Ru0zl2YiW3Rm/fJCwIeM0nToenfOJKA= +github.com/pion/randutil v0.1.0 h1:CFG1UdESneORglEsnimhUjf33Rwjubwj6xfiOXBa3mA= +github.com/pion/randutil v0.1.0/go.mod h1:XcJrSMMbbMRhASFVOlj/5hQial/Y8oH/HVo7TBZq+j8= +github.com/pion/rtcp v1.2.14 h1:KCkGV3vJ+4DAJmvP0vaQShsb0xkRfWkO540Gy102KyE= +github.com/pion/rtcp v1.2.14/go.mod h1:sn6qjxvnwyAkkPzPULIbVqSKI5Dv54Rv7VG0kNxh9L4= +github.com/pion/rtp v1.8.9 h1:E2HX740TZKaqdcPmf4pw6ZZuG8u5RlMMt+l3dxeu6Wk= +github.com/pion/rtp v1.8.9/go.mod h1:pBGHaFt/yW7bf1jjWAoUjpSNoDnw98KTMg+jWWvziqU= +github.com/pion/sctp v1.8.33 h1:dSE4wX6uTJBcNm8+YlMg7lw1wqyKHggsP5uKbdj+NZw= +github.com/pion/sctp v1.8.33/go.mod h1:beTnqSzewI53KWoG3nqB282oDMGrhNxBdb+JZnkCwRM= +github.com/pion/sdp/v3 v3.0.9 h1:pX++dCHoHUwq43kuwf3PyJfHlwIj4hXA7Vrifiq0IJY= +github.com/pion/sdp/v3 v3.0.9/go.mod h1:B5xmvENq5IXJimIO4zfp6LAe1fD9N+kFv+V/1lOdz8M= +github.com/pion/srtp/v3 v3.0.3 h1:tRtEOpmR8NtsB/KndlKXFOj/AIIs6aPrCq4TlAatC4M= +github.com/pion/srtp/v3 v3.0.3/go.mod h1:Bp9ztzPCoE0ETca/R+bTVTO5kBgaQMiQkTmZWwazDTc= +github.com/pion/stun/v3 v3.0.0 h1:4h1gwhWLWuZWOJIJR9s2ferRO+W3zA/b6ijOI6mKzUw= +github.com/pion/stun/v3 v3.0.0/go.mod h1:HvCN8txt8mwi4FBvS3EmDghW6aQJ24T+y+1TKjB5jyU= +github.com/pion/transport/v3 v3.0.7 h1:iRbMH05BzSNwhILHoBoAPxoB9xQgOaJk+591KC9P1o0= +github.com/pion/transport/v3 v3.0.7/go.mod h1:YleKiTZ4vqNxVwh77Z0zytYi7rXHl7j6uPLGhhz9rwo= +github.com/pion/turn/v4 v4.0.0 h1:qxplo3Rxa9Yg1xXDxxH8xaqcyGUtbHYw4QSCvmFWvhM= +github.com/pion/turn/v4 v4.0.0/go.mod h1:MuPDkm15nYSklKpN8vWJ9W2M0PlyQZqYt1McGuxG7mA= +github.com/pion/webrtc/v4 v4.0.0-beta.30 h1:ztchBW2RZiiBflmoCIuViD/axDoNkEzoh0CqRWvf6dc= +github.com/pion/webrtc/v4 v4.0.0-beta.30/go.mod h1:V+nZxyUG8sIUb0uUYQEZzx1PvMPtHlRby4h3xhrjTsg= github.com/pkg/errors v0.8.0/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= @@ -440,6 +478,7 @@ github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+ github.com/stretchr/objx v0.1.1/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= +github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= github.com/stretchr/testify v1.2.0/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= @@ -448,8 +487,10 @@ github.com/stretchr/testify v1.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5 github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= -github.com/stretchr/testify v1.8.1 h1:w7B6lhMri9wdJUVmEZPGGhZzrYTPvgJArz7wNPgYKsk= github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= +github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= +github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg= +github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= github.com/syndtr/goleveldb v1.0.1-0.20210819022825-2ae1ddf74ef7 h1:epCh84lMvA70Z7CTTCmYQn2CKbY8j86K7/FAIr141uY= github.com/syndtr/goleveldb v1.0.1-0.20210819022825-2ae1ddf74ef7/go.mod h1:q4W45IWZaF22tdD+VEXcAWRA037jwmWEB5VWYORlTpc= github.com/tinylib/msgp v1.0.2/go.mod h1:+d+yLhGm8mzTaHzB+wgMYrodPfmZrzkirds8fDWklFE= @@ -476,6 +517,8 @@ github.com/wealdtech/go-multicodec v1.4.0/go.mod h1:aedGMaTeYkIqi/KCPre1ho5rTb3h github.com/wealdtech/go-string2eth v1.1.0 h1:USJQmysUrBYYmZs7d45pMb90hRSyEwizP7lZaOZLDAw= github.com/wealdtech/go-string2eth v1.1.0/go.mod h1:RUzsLjJtbZaJ/3UKn9kY19a/vCCUHtEWoUW3uiK6yGU= github.com/willf/bitset v1.1.3/go.mod h1:RjeCKbqT1RxIR/KWY6phxZiaY1IyutSBfGjNPySAYV4= +github.com/wlynxg/anet v0.0.4 h1:0de1OFQxnNqAu+x2FAKKCVIrnfGKQbs7FQz++tB0+Uw= +github.com/wlynxg/anet v0.0.4/go.mod h1:eay5PRQr7fIVAMbTbchTnO9gG65Hg/uYGdc7mguHxoA= github.com/xlab/treeprint v0.0.0-20180616005107-d6fb6747feb6/go.mod h1:ce1O1j6UtZfjr22oyGxGLbauSBp2YVXpARAosm7dHBg= github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= github.com/yusufpapurcu/wmi v1.2.2 h1:KBNDSne4vP5mbSWnJbO+51IMOXJB67QiYCSBrubbPRg= @@ -502,8 +545,8 @@ golang.org/x/crypto v0.0.0-20210220033148-5ea612d1eb83/go.mod h1:jdWPYTVW3xRLrWP golang.org/x/crypto v0.0.0-20210322153248-0c34fe9e7dc2/go.mod h1:T9bdIzuCu7OtxOm1hfPfRQxPLYneinmdGuTeoZ9dtd4= golang.org/x/crypto v0.0.0-20210506145944-38f3c27a63bf/go.mod h1:P+XmwS30IXTQdn5tA2iutPOUgjI07+tq3H3K9MVA1s8= golang.org/x/crypto v0.0.0-20220213190939-1e6e3497d506/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4= -golang.org/x/crypto v0.7.0 h1:AvwMYaRytfdeVt3u6mLaxYtErKYjxA2OXjJ1HHq6t3A= -golang.org/x/crypto v0.7.0/go.mod h1:pYwdfH91IfpZVANVyUOhSIPZaFoJGxTFbZhFTx+dXZU= +golang.org/x/crypto v0.31.0 h1:ihbySMvVjLAeSH1IbfcRTkD/iNscyz8rGzjF/E5hV6U= +golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk= golang.org/x/exp v0.0.0-20180321215751-8460e604b9de/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= golang.org/x/exp v0.0.0-20180807140117-3d87b88a115f/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= @@ -527,15 +570,15 @@ golang.org/x/lint v0.0.0-20190930215403-16217165b5de/go.mod h1:6SW0HCj/g11FgYtHl golang.org/x/lint v0.0.0-20191125180803-fdd1cda4f05f/go.mod h1:5qLYkcX4OjUUV8bRuDixDT3tpyyb+LUpUlRWLxfhWrs= golang.org/x/mobile v0.0.0-20190312151609-d3739f865fa6/go.mod h1:z+o9i4GpDbdi3rU15maQ/Ox0txvL9dWGYEHz965HBQE= golang.org/x/mobile v0.0.0-20190719004257-d2bd2a29d028/go.mod h1:E/iHnbuqvinMTCcRqshq8CkpyQDoeVncDDYHnLhea+o= -golang.org/x/mobile v0.0.0-20230301163155-e0f57694e12c h1:Gk61ECugwEHL6IiyyNLXNzmu8XslmRP2dS0xjIYhbb4= -golang.org/x/mobile v0.0.0-20230301163155-e0f57694e12c/go.mod h1:aAjjkJNdrh3PMckS4B10TGS2nag27cbKR1y2BpUxsiY= +golang.org/x/mobile v0.0.0-20240905004112-7c4916698cc9 h1:zWHXwU92JPmIJw5bj6vOZWDZusVxqazOaqhwfUA5l7w= +golang.org/x/mobile v0.0.0-20240905004112-7c4916698cc9/go.mod h1:udWezQGYjqrCxz5nV321pXQTx5oGbZx+khZvFjZNOPM= golang.org/x/mod v0.0.0-20190513183733-4bf6d317e70e/go.mod h1:mXi4GBBbnImb6dmsKGUJ2LatrhH/nqhxcFungHvyanc= golang.org/x/mod v0.1.0/go.mod h1:0QHyrYULN0/3qlju5TqG8bIK38QM8yzMo5ekMj3DlcY= golang.org/x/mod v0.1.1-0.20191105210325-c90efee705ee/go.mod h1:QqPTAvyqsEbceGzBzNggFXnrqF1CaUcvgkdR5Ot7KZg= golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= golang.org/x/mod v0.4.2/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= -golang.org/x/mod v0.8.0 h1:LUYupSeNrTNCGzR/hVBk2NHZO4hXcVaW1k4Qx7rjPx8= -golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= +golang.org/x/mod v0.20.0 h1:utOm6MM3R3dnawAiJgn0y+xvuYRsm1RKM/4giyfDgV0= +golang.org/x/mod v0.20.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= golang.org/x/net v0.0.0-20180719180050-a680a1efc54d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= @@ -562,8 +605,8 @@ golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v golang.org/x/net v0.0.0-20210805182204-aaa1db679c0d/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= golang.org/x/net v0.0.0-20220127200216-cd36cc0744dd/go.mod h1:CfG3xpIq0wQ8r1q4Su4UZFWDARRcnwPjda9FqA0JpMk= -golang.org/x/net v0.8.0 h1:Zrh2ngAOFYneWTAIAPethzeaQLuHwhuBkuV6ZiRnUaQ= -golang.org/x/net v0.8.0/go.mod h1:QVkue5JL9kW//ek3r6jTKnTFis1tRmNAW2P1shuFdJc= +golang.org/x/net v0.33.0 h1:74SYHlV8BIgHIFC/LrYkOGIwL19eTYXQ5wc6TBuO36I= +golang.org/x/net v0.33.0/go.mod h1:HXLR5J+9DxmrqMwG9qjGCxZ+zKXxBru04zlTvWlWuN4= golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U= golang.org/x/oauth2 v0.0.0-20190226205417-e64efc72b421/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= golang.org/x/oauth2 v0.0.0-20190604053449-0f29369cfe45/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= @@ -578,7 +621,8 @@ golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJ golang.org/x/sync v0.0.0-20200317015054-43a5402ce75a/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.1.0 h1:wsuoTGHzEhffawBOhz5CYhcrV4IdKZbEyZjBMuTp12o= +golang.org/x/sync v0.10.0 h1:3NQrjDixjgGwUOCaF8w2+VYHv0Ve/vGYSbdkTa98gmQ= +golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20180905080454-ebe1bf3edb33/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20180909124046-d0be0721c37e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= @@ -624,10 +668,10 @@ golang.org/x/sys v0.0.0-20210816183151-1e6c022a8912/go.mod h1:oPkhp1MJrh7nUepCBc golang.org/x/sys v0.0.0-20211216021012-1d35b9e2eb4e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220128215802-99c3d69c2c27/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220209214540-3681064d5158/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.0.0-20220704084225-05e143d24a9e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.2.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.6.0 h1:MVltZSvRTcU2ljQOhs94SXPftV6DCNnZViHeQps87pQ= -golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.28.0 h1:Fksou7UEQUWlKvIdsqzJmUmCX3cZuD2+P3XyyzwMhlA= +golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/term v0.0.0-20201117132131-f5c789dd3221/go.mod h1:Nr5EML6q2oocZ2LXRh80K7BxOlk5/8JxuGnuhpl+muw= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= @@ -639,14 +683,15 @@ golang.org/x/text v0.3.4/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.5/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= -golang.org/x/text v0.8.0 h1:57P1ETyNKtuIjB4SRd15iJxuhj8Gc416Y78H3qgMh68= -golang.org/x/text v0.8.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8= +golang.org/x/text v0.21.0 h1:zyQAAkrwaneQ066sspRyJaG9VNi/YJ1NfzcGB3hZ/qo= +golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ= golang.org/x/time v0.0.0-20181108054448-85acf8d2951c/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= golang.org/x/time v0.0.0-20190308202827-9d24e82272b4/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= golang.org/x/time v0.0.0-20201208040808-7e3f01d25324/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= golang.org/x/time v0.0.0-20210220033141-f8bda1e9f3ba/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= -golang.org/x/time v0.3.0 h1:rg5rLMjNzMS1RkNLzCG38eapWhnYLFYXDXj2gOlr8j4= golang.org/x/time v0.3.0/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= +golang.org/x/time v0.6.0 h1:eTDhh4ZXt5Qf0augr54TN6suAUudPcawVZeIAPU7D4U= +golang.org/x/time v0.6.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM= golang.org/x/tools v0.0.0-20180525024113-a5b4c53f6e8b/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20181030221726-6c7e314b6563/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= @@ -674,12 +719,11 @@ golang.org/x/tools v0.0.0-20191216173652-a0e659d51361/go.mod h1:TB2adYChydJhpapK golang.org/x/tools v0.0.0-20191227053925-7b8e75db28f4/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= golang.org/x/tools v0.0.0-20200108203644-89082a384178/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= golang.org/x/tools v0.1.0/go.mod h1:xkSsbof2nBLbhDlRMhhhyNLN/zl3eTqcnHD5viDpcZ0= -golang.org/x/tools v0.6.0 h1:BOw41kyTf3PuCW1pVQf8+Cyg8pMlkYB1oo9iJ6D/lKM= -golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU= +golang.org/x/tools v0.24.0 h1:J1shsA93PJUEVaUSaay7UXAyE8aimq3GW0pjlolpa24= +golang.org/x/tools v0.24.0/go.mod h1:YhNqVBIfWHdzvTLs0d8LCuMhkKUgSUKldakyV7W/WDQ= golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= -golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1 h1:go1bK/D/BFZV2I8cIQd1NKEZ+0owSTG1fDTci4IqFcE= golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= gonum.org/v1/gonum v0.0.0-20180816165407-929014505bf4/go.mod h1:Y+Yx5eoAFn32cQvJDxZx5Dpnq+c3wtXuadVZAcxbbBo= gonum.org/v1/gonum v0.0.0-20181121035319-3f7ecaa7e8ca/go.mod h1:Y+Yx5eoAFn32cQvJDxZx5Dpnq+c3wtXuadVZAcxbbBo= @@ -723,12 +767,11 @@ google.golang.org/protobuf v0.0.0-20200221191635-4d8936d0db64/go.mod h1:kwYJMbMJ google.golang.org/protobuf v0.0.0-20200228230310-ab0ca4ff8a60/go.mod h1:cfTl7dwQJ+fmap5saPgwCLgHXTUD7jkjRqWcaiX5VyM= google.golang.org/protobuf v1.20.1-0.20200309200217-e05f789c0967/go.mod h1:A+miEFZTKqfCUM6K7xSMQL9OKL/b6hQv+e19PK+JZNE= google.golang.org/protobuf v1.21.0/go.mod h1:47Nbq4nVaFHyn7ilMalzfO3qCViNmqZ2kzikPIcrTAo= -google.golang.org/protobuf v1.22.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU= google.golang.org/protobuf v1.23.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU= google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw= google.golang.org/protobuf v1.26.0/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc= -google.golang.org/protobuf v1.30.0 h1:kPPoIgf3TsEvrm0PFe15JQ+570QVxYzEvvHqChK+cng= -google.golang.org/protobuf v1.30.0/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I= +google.golang.org/protobuf v1.34.2 h1:6xV6lTsCfpGD21XK49h7MhtcApnLqkfYgPcdHftf6hg= +google.golang.org/protobuf v1.34.2/go.mod h1:qYOHts0dSfpeUzUFpOMr/WGzszTmLH+DiWniOlNbLDw= gopkg.in/alecthomas/kingpin.v2 v2.2.6/go.mod h1:FMv+mEhP44yOT+4EoQTLFTRgOQ1FBLkstjWtayDeSgw= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= diff --git a/golib/gomobile.go b/golib/gomobile.go index 289b9e6aa..398ff70f9 100644 --- a/golib/gomobile.go +++ b/golib/gomobile.go @@ -1,6 +1,8 @@ package nkngolib import ( + "nkngolib/search" + dnsresolver "github.com/nknorg/dns-resolver-go" ethresolver "github.com/nknorg/eth-resolver-go" "github.com/nknorg/nkn-sdk-go" @@ -16,4 +18,5 @@ var ( _ = nkngomobile.NewStringArray _ = reedsolomon.New _ = bind.GenGo + _ = search.NewSearchClient ) diff --git a/golib/search/pow.go b/golib/search/pow.go new file mode 100644 index 000000000..11a408332 --- /dev/null +++ b/golib/search/pow.go @@ -0,0 +1,392 @@ +package search + +import ( + "bytes" + "crypto/ed25519" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "fmt" + "io" + "log" + "net/http" + "net/url" + "runtime" + "strconv" + "sync" + "time" +) + +// Challenge represents the PoW challenge structure +type Challenge struct { + Challenges []string `json:"challenges"` + Difficulty int `json:"difficulty"` + Count int `json:"count"` + Hint string `json:"hint"` +} + +// ChallengeResponse represents the API response for challenge request +type ChallengeResponse struct { + Success bool `json:"success"` + Data Challenge `json:"data"` + Error string `json:"error,omitempty"` +} + +// Solution represents the solution for a single challenge +type Solution struct { + Challenge string `json:"challenge"` + Signature string `json:"signature"` + Nonce string `json:"nonce"` +} + +// VerifyRequest represents the verification request +type VerifyRequest struct { + PublicKey string `json:"publicKey"` + Solutions []Solution `json:"solutions"` +} + +// VerifyResponse represents the verification response +type VerifyResponse struct { + Success bool `json:"success"` + Message string `json:"message"` + Data struct { + PublicKey string `json:"publicKey"` + VerifiedAt int64 `json:"verifiedAt"` + } `json:"data"` + Error string `json:"error,omitempty"` +} + +// PowSolution represents the PoW solution for data submission +type PowSolution struct { + PublicKey string `json:"publicKey"` + Solutions []Solution `json:"solutions"` +} + +// SubmitRequest represents the data submission request +type SubmitRequest struct { + PublicKey string `json:"publicKey"` + PowSolution PowSolution `json:"powSolution"` + NknAddress string `json:"nknAddress"` + CustomId string `json:"customId,omitempty"` + Nickname string `json:"nickname,omitempty"` + PhoneNumber string `json:"phoneNumber,omitempty"` +} + +// SubmitResponse represents the submission response +type SubmitResponse struct { + Success bool `json:"success"` + Message string `json:"message"` + Error string `json:"error,omitempty"` +} + +// SearchClient is the search client for NKN search server +type SearchClient struct { + apiBase *url.URL + privateKey []byte + publicKey []byte + walletAddr string + isVerified bool + verifiedUntil time.Time + mu sync.RWMutex + httpClient *http.Client +} + +// init initializes the search package +// Sets GOMAXPROCS to enable parallel execution on multi-core devices +func init() { + // Enable parallel execution by setting GOMAXPROCS to CPU count + numCPU := runtime.NumCPU() + runtime.GOMAXPROCS(numCPU) + log.Printf("Search package initialized: GOMAXPROCS set to %d (CPU cores: %d)", + runtime.GOMAXPROCS(0), numCPU) +} + +// QueryResult represents the query result +type QueryResult struct { + Success bool `json:"success"` + Data string `json:"data"` // JSON formatted result + Error string `json:"error,omitempty"` +} + +// sign signs a message (hex string) +func (c *SearchClient) sign(messageHex string) (string, error) { + // NKN SDK expects message to be a hex string, need to decode first + messageBytes, err := hex.DecodeString(messageHex) + if err != nil { + return "", fmt.Errorf("failed to decode hex message: %w", err) + } + + // Sign using ed25519 + signature := ed25519.Sign(c.privateKey, messageBytes) + + // Return hex encoded signature + return hex.EncodeToString(signature), nil +} + +// GetPublicKeyHex returns the public key in hex format +func (c *SearchClient) GetPublicKeyHex() string { + return hex.EncodeToString(c.publicKey) +} + +// GetAddress returns the wallet address +func (c *SearchClient) GetAddress() string { + return c.walletAddr +} + +// IsVerified checks if the client is verified +func (c *SearchClient) IsVerified() bool { + c.mu.RLock() + defer c.mu.RUnlock() + return c.isVerified && time.Now().Before(c.verifiedUntil) +} + +// solvePoW calculates PoW - finds a nonce that satisfies the difficulty +// Single-threaded optimized version for maximum performance on mobile devices +func solvePoW(signature string, difficulty int) (string, time.Duration) { + startTime := time.Now() + + log.Printf("Starting single-threaded PoW calculation (difficulty: %d)", difficulty) + + // Pre-convert signature to bytes once + sigBytes := []byte(signature) + + // Calculate how many leading zero bits we need + zeroBits := difficulty * 4 // Each hex digit = 4 bits + zeroBytes := zeroBits / 8 + remainingBits := zeroBits % 8 + + // Pre-allocate buffer with enough space + buf := make([]byte, len(sigBytes), len(sigBytes)+20) + copy(buf, sigBytes) + + nonce := 0 + + // Keep track of where signature ends + sigLen := len(sigBytes) + + // Single-threaded tight loop - maximum performance + for { + // Build data: signature + nonce (optimized - reuse buffer) + buf = buf[:sigLen] + buf = strconv.AppendInt(buf, int64(nonce), 10) + + // Calculate hash + hash := sha256.Sum256(buf) + + // Fast check: compare bytes directly instead of hex string + isValid := true + + // Check full zero bytes + for k := 0; k < zeroBytes; k++ { + if hash[k] != 0 { + isValid = false + break + } + } + + // Check remaining bits if needed + if isValid && remainingBits > 0 { + mask := byte(0xFF << (8 - remainingBits)) + if (hash[zeroBytes] & mask) != 0 { + isValid = false + } + } + + if isValid { + duration := time.Since(startTime) + log.Printf("PoW solved: nonce=%d, duration=%v", nonce, duration) + return strconv.Itoa(nonce), duration + } + + nonce++ + } +} + +// getChallenge gets the PoW challenge +func (c *SearchClient) getChallenge() (*Challenge, error) { + url := fmt.Sprintf("%s/auth/challenge?publicKey=%s", c.apiBase, c.GetPublicKeyHex()) + resp, err := c.httpClient.Get(url) + if err != nil { + return nil, err + } + defer resp.Body.Close() + + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, err + } + + var challengeResp ChallengeResponse + if err := json.Unmarshal(body, &challengeResp); err != nil { + return nil, err + } + + if !challengeResp.Success { + return nil, fmt.Errorf("failed to get challenge: %s", challengeResp.Error) + } + + return &challengeResp.Data, nil +} + +// getChallengeSubmit gets the PoW challenge for data submission +func (c *SearchClient) getChallengeSubmit() (*Challenge, error) { + url := fmt.Sprintf("%s/auth/challenge-submit?publicKey=%s", c.apiBase, c.GetPublicKeyHex()) + resp, err := c.httpClient.Get(url) + if err != nil { + return nil, err + } + defer resp.Body.Close() + + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, err + } + + var challengeResp ChallengeResponse + if err := json.Unmarshal(body, &challengeResp); err != nil { + return nil, err + } + + if !challengeResp.Success { + return nil, fmt.Errorf("failed to get submit challenge: %s", challengeResp.Error) + } + + return &challengeResp.Data, nil +} + +// verify submits PoW solutions for verification +func (c *SearchClient) verify(solutions []Solution) (*VerifyResponse, error) { + url := fmt.Sprintf("%s/auth/verify", c.apiBase) + + reqBody := VerifyRequest{ + PublicKey: c.GetPublicKeyHex(), + Solutions: solutions, + } + + jsonData, err := json.Marshal(reqBody) + if err != nil { + return nil, err + } + + resp, err := c.httpClient.Post(url, "application/json", bytes.NewBuffer(jsonData)) + if err != nil { + return nil, err + } + defer resp.Body.Close() + + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, err + } + + var verifyResp VerifyResponse + if err := json.Unmarshal(body, &verifyResp); err != nil { + return nil, err + } + + return &verifyResp, nil +} + +// submitData submits user data (requires fresh PoW) +func (c *SearchClient) submitData(nknAddress, customId, nickname, phoneNumber string) (*SubmitResponse, error) { + log.Printf("Starting user data submission...") + + // 1. Get submit challenge + challenge, err := c.getChallengeSubmit() + if err != nil { + return nil, fmt.Errorf("failed to get submit challenge: %w", err) + } + + // 2. Solve challenges + solutions, err := c.solveChallenges(challenge) + if err != nil { + return nil, fmt.Errorf("failed to solve challenges: %w", err) + } + + // 3. Prepare and submit data + url := c.apiBase.JoinPath("data/submit") + + powSolution := PowSolution{ + PublicKey: c.GetPublicKeyHex(), + Solutions: solutions, + } + + reqBody := SubmitRequest{ + PublicKey: c.GetPublicKeyHex(), + PowSolution: powSolution, + NknAddress: nknAddress, + CustomId: customId, + Nickname: nickname, + PhoneNumber: phoneNumber, + } + + jsonData, err := json.Marshal(reqBody) + if err != nil { + return nil, err + } + + log.Printf("Submitting user data to server...") + resp, err := c.httpClient.Post(url.String(), "application/json", bytes.NewBuffer(jsonData)) + if err != nil { + return nil, err + } + defer resp.Body.Close() + + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, err + } + + // Handle rate limit with detailed message + if resp.StatusCode == 429 { + log.Printf("⚠️ Rate limit exceeded. Server allows max 10 submits per minute.") + log.Printf("Please wait a moment before retrying.") + return nil, fmt.Errorf("rate limit exceeded (429): max 10 submits per minute. Please wait and retry") + } + + var submitResp SubmitResponse + if err := json.Unmarshal(body, &submitResp); err != nil { + return nil, err + } + + if submitResp.Success { + log.Printf("✓ User data submitted successfully: %s", submitResp.Message) + } else { + log.Printf("✗ User data submission failed: %s", submitResp.Error) + } + + return &submitResp, nil +} + +// solveChallenges solves all challenges +func (c *SearchClient) solveChallenges(challenge *Challenge) ([]Solution, error) { + solutions := make([]Solution, 0, len(challenge.Challenges)) + totalStart := time.Now() + + log.Printf("Starting to solve %d challenges with difficulty %d", len(challenge.Challenges), challenge.Difficulty) + + for i, ch := range challenge.Challenges { + // Sign the challenge + signature, err := c.sign(ch) + if err != nil { + return nil, fmt.Errorf("failed to sign challenge: %w", err) + } + + // Calculate PoW + log.Printf("Solving challenge %d/%d...", i+1, len(challenge.Challenges)) + nonce, duration := solvePoW(signature, challenge.Difficulty) + log.Printf("Challenge %d/%d solved in %v (nonce: %s)", i+1, len(challenge.Challenges), duration, nonce) + + solutions = append(solutions, Solution{ + Challenge: ch, + Signature: signature, + Nonce: nonce, + }) + } + + totalDuration := time.Since(totalStart) + log.Printf("All challenges solved! Total time: %v (avg: %v per challenge)", + totalDuration, totalDuration/time.Duration(len(challenge.Challenges))) + + return solutions, nil +} diff --git a/golib/search/search.go b/golib/search/search.go new file mode 100644 index 000000000..09002804c --- /dev/null +++ b/golib/search/search.go @@ -0,0 +1,285 @@ +package search + +import ( + "crypto/ed25519" + "fmt" + "io" + "log" + "net/http" + "net/url" + "strings" + "time" + + "github.com/nknorg/nkn-sdk-go" +) + +// NewSearchClient creates a new search client +// apiBase: API server address, e.g. "https://search.nkn.org/api/v1" +// For query-only usage, you can pass empty strings for privateKeyHex, publicKeyHex, and walletAddr +func NewSearchClient(apiBase string) (*SearchClient, error) { + apiBaseURL, err := url.Parse(apiBase) + if err != nil { + return nil, fmt.Errorf("failed to parse API base URL: %w", err) + } + return &SearchClient{ + apiBase: apiBaseURL, + httpClient: &http.Client{Timeout: 30 * time.Second}, + }, nil +} + +// NewSearchClientWithAuth creates a new search client with authentication +// apiBase: API server address, e.g. "https://search.nkn.org/api/v1" +// seed: NKN seed (hex format, 32 bytes = 64 characters) +func NewSearchClientWithAuth(apiBase string, seed []byte) (*SearchClient, error) { + account, err := nkn.NewAccount(seed) + if err != nil { + return nil, fmt.Errorf("failed to create account: %w", err) + } + + // Decode private key + privateKeyBytes := account.PrivateKey + if len(privateKeyBytes) != ed25519.PrivateKeySize { + return nil, fmt.Errorf("private key must be %d bytes", ed25519.PrivateKeySize) + } + + // Decode public key + publicKeyBytes := account.PublicKey + if len(publicKeyBytes) != ed25519.PublicKeySize { + return nil, fmt.Errorf("public key must be %d bytes", ed25519.PublicKeySize) + } + + apiBaseURL, err := url.Parse(apiBase) + if err != nil { + return nil, fmt.Errorf("failed to parse API base URL: %w", err) + } + + return &SearchClient{ + apiBase: apiBaseURL, + privateKey: privateKeyBytes, + publicKey: publicKeyBytes, + walletAddr: account.WalletAddress(), + httpClient: &http.Client{Timeout: 30 * time.Second}, + }, nil +} + +// Query queries data by keyword +// keyword: search keyword +// Returns JSON formatted query result string, error on failure +func (c *SearchClient) Query(keyword string) (string, error) { + // Build query URL + url := c.apiBase.JoinPath("data/query") + q := url.Query() + q.Set("q", keyword) + url.RawQuery = q.Encode() + + // Create request + req, err := http.NewRequest("GET", url.String(), nil) + if err != nil { + return "", err + } + + // Send request + resp, err := c.httpClient.Do(req) + if err != nil { + return "", fmt.Errorf("query request failed: %w", err) + } + defer resp.Body.Close() + + // Read response + body, err := io.ReadAll(resp.Body) + if err != nil { + return "", fmt.Errorf("failed to read response: %w", err) + } + + // Return JSON string + return string(body), nil +} + +// QueryByID queries data by ID +// id: ID +// Returns JSON formatted query result string, error on failure +func (c *SearchClient) QueryByID(id string) (string, error) { + // Build query URL + url := c.apiBase.JoinPath("data/query") + q := url.Query() + q.Set("customId", id) + url.RawQuery = q.Encode() + + // Create request + req, err := http.NewRequest("GET", url.String(), nil) + if err != nil { + return "", err + } + + // Send request + resp, err := c.httpClient.Do(req) + if err != nil { + return "", fmt.Errorf("query request failed: %w", err) + } + defer resp.Body.Close() + + // Read response + body, err := io.ReadAll(resp.Body) + if err != nil { + return "", fmt.Errorf("failed to read response: %w", err) + } + + // Return JSON string + return string(body), nil +} + +// GetMyInfo queries my own information by querying with nknAddress +// address: NKN address +// Returns JSON formatted query result string, error on failure +func (c *SearchClient) GetMyInfo(address string) (string, error) { + // Build query URL + url := c.apiBase.JoinPath("data/query") + q := url.Query() + q.Set("nknAddress", address) + url.RawQuery = q.Encode() + + log.Printf("[GetMyInfo] Request URL: %s", url.String()) + + // Create request + req, err := http.NewRequest("GET", url.String(), nil) + if err != nil { + return "", err + } + + // Send request + resp, err := c.httpClient.Do(req) + if err != nil { + return "", fmt.Errorf("query request failed: %w", err) + } + defer resp.Body.Close() + + log.Printf("[GetMyInfo] Response status: %d %s", resp.StatusCode, resp.Status) + + // Read response + body, err := io.ReadAll(resp.Body) + if err != nil { + return "", fmt.Errorf("failed to read response: %w", err) + } + + // Check HTTP status code + if resp.StatusCode != http.StatusOK { + log.Printf("[GetMyInfo] Error response body: %s", string(body)) + return "", fmt.Errorf("HTTP %d: %s", resp.StatusCode, string(body)) + } + + log.Printf("[GetMyInfo] Response body: %s", string(body)) + + // Return JSON string + return string(body), nil +} + +// SubmitUserData submits or updates user profile data to the search server +// +// Parameters: +// - nknAddress: NKN client address (optional, format: "identifier.publickey" or just publickey) +// If empty, defaults to publickey. Must be either "identifier.publickey" format or equal to publickey. +// - customId: Custom identifier (optional, min 3 characters if provided, alphanumeric + underscore only) +// - nickname: User nickname (optional, can be empty string) +// - phoneNumber: Phone number (optional, can be empty string) +// +// # Returns nil on success, error on failure +// +// Important notes: +// - Each call performs fresh PoW (Proof of Work) automatically - you'll see timing logs +// - Server rate limit: 10 submits per minute +// - NO need to call Verify() first - SubmitUserData works independently +// - Verify() is only useful for query operations (gives 2-hour query access) +// - If publicKey already exists, will UPDATE the user data (can modify nickname, phoneNumber) +// - nknAddress validation: must be empty, equal to publickey, or in "identifier.publickey" format +// +// Example: +// +// // Option 1: Use default (empty - will use publickey) +// err := client.SubmitUserData("", "", "John Doe", "13800138000") +// +// // Option 2: Use publickey directly +// err := client.SubmitUserData(client.GetPublicKeyHex(), "", "John Doe", "13800138000") +// +// // Option 3: Use custom identifier.publickey format +// err := client.SubmitUserData( +// "alice." + client.GetPublicKeyHex(), // nknAddress - identifier.publickey +// "myid123", // customId - optional +// "John Doe", // nickname - optional +// "13800138000", // phoneNumber - optional +// ) +func (c *SearchClient) SubmitUserData(nknAddress, customId, nickname, phoneNumber string) error { + // Process nknAddress: if empty, use publicKey + finalNknAddress := nknAddress + publicKeyHex := c.GetPublicKeyHex() + + if finalNknAddress == "" { + finalNknAddress = publicKeyHex + } else { + // Validate format if contains dot + if strings.Contains(finalNknAddress, ".") { + parts := strings.Split(finalNknAddress, ".") + if len(parts) != 2 { + return fmt.Errorf("invalid nknAddress format, expected: identifier.publickey") + } + providedPubKey := parts[1] + if strings.ToLower(providedPubKey) != strings.ToLower(publicKeyHex) { + return fmt.Errorf("nknAddress publickey suffix must match your actual publicKey") + } + } else { + // If no dot, must equal publicKey + if strings.ToLower(finalNknAddress) != strings.ToLower(publicKeyHex) { + return fmt.Errorf("nknAddress must be either \"identifier.publickey\" format or equal to publicKey") + } + } + } + + // Validate customId if provided + if customId != "" && len(customId) < 3 { + return fmt.Errorf("customId must be at least 3 characters if provided") + } + + submitResp, err := c.submitData(finalNknAddress, customId, nickname, phoneNumber) + if err != nil { + return fmt.Errorf("failed to submit user data: %w", err) + } + + if !submitResp.Success { + return fmt.Errorf("submit failed: %s", submitResp.Error) + } + + return nil +} + +// Verify verifies the public key (completes PoW challenge) +// Returns nil on success, error on failure +func (c *SearchClient) Verify() error { + // 1. Get challenge + challenge, err := c.getChallenge() + if err != nil { + return fmt.Errorf("failed to get challenge: %w", err) + } + + // 2. Solve challenges + solutions, err := c.solveChallenges(challenge) + if err != nil { + return fmt.Errorf("failed to solve challenges: %w", err) + } + + // 3. Submit verification + verifyResp, err := c.verify(solutions) + if err != nil { + return fmt.Errorf("failed to verify: %w", err) + } + + if !verifyResp.Success { + return fmt.Errorf("verification failed: %s", verifyResp.Error) + } + + // 4. Update status + c.mu.Lock() + c.isVerified = true + c.verifiedUntil = time.Now().Add(2 * time.Hour) // Valid for 2 hours + c.mu.Unlock() + + return nil +} diff --git a/ios/Classes/impl/SearchService/Search.swift b/ios/Classes/impl/SearchService/Search.swift new file mode 100644 index 000000000..245e8b291 --- /dev/null +++ b/ios/Classes/impl/SearchService/Search.swift @@ -0,0 +1,368 @@ +import Nkn + +class SearchService : ChannelBase, FlutterStreamHandler { + static var instance: SearchService = SearchService() + let searchQueue = DispatchQueue(label: "org.nkn.mobile/native/search/queue", qos: .default, attributes: .concurrent) + + // High priority queue for CPU-intensive PoW calculations + // Use userInitiated QoS to ensure maximum CPU resources + let powQueue = DispatchQueue(label: "org.nkn.mobile/native/search/pow", qos: .userInitiated, attributes: .concurrent) + + private var searchItem: DispatchWorkItem? + + var methodChannel: FlutterMethodChannel? + let METHOD_CHANNEL_NAME = "org.nkn.mobile/native/search" + var eventSink: FlutterEventSink? + + // Store search client instances by ID + private var clients: [String: SearchSearchClient] = [:] + private let clientsLock = NSLock() + + public static func register(controller: FlutterViewController) { + instance.install(binaryMessenger: controller as! FlutterBinaryMessenger) + } + + func install(binaryMessenger: FlutterBinaryMessenger) { + self.methodChannel = FlutterMethodChannel(name: METHOD_CHANNEL_NAME, binaryMessenger: binaryMessenger) + self.methodChannel?.setMethodCallHandler(handle) + } + + func uninstall() { + self.methodChannel?.setMethodCallHandler(nil) + } + + func onListen(withArguments arguments: Any?, eventSink events: @escaping FlutterEventSink) -> FlutterError? { + eventSink = events + return nil + } + + func onCancel(withArguments arguments: Any?) -> FlutterError? { + eventSink = nil + return nil + } + + private func handle(_ call: FlutterMethodCall, result: @escaping FlutterResult) { + switch call.method { + case "newSearchClient": + newSearchClient(call, result: result) + case "newSearchClientWithAuth": + newSearchClientWithAuth(call, result: result) + case "query": + query(call, result: result) + case "submitUserData": + submitUserData(call, result: result) + case "verify": + verify(call, result: result) + case "queryByID": + queryByID(call, result: result) + case "getMyInfo": + getMyInfo(call, result: result) + case "getPublicKeyHex": + getPublicKeyHex(call, result: result) + case "getAddress": + getAddress(call, result: result) + case "isVerified": + isVerified(call, result: result) + case "disposeClient": + disposeClient(call, result: result) + default: + result(FlutterMethodNotImplemented) + } + } + + // Create a query-only search client + private func newSearchClient(_ call: FlutterMethodCall, result: @escaping FlutterResult) { + let args = call.arguments as? [String: Any] ?? [String: Any]() + let apiBase = args["apiBase"] as? String ?? "" + + var error: NSError? + guard let client = SearchNewSearchClient(apiBase, &error) else { + self.resultError(result: result, error: error, code: "CREATE_CLIENT_FAILED") + return + } + + // Generate unique ID for this client + let clientId = UUID().uuidString + + clientsLock.lock() + clients[clientId] = client + clientsLock.unlock() + + let response: [String: Any] = [ + "clientId": clientId + ] + + self.resultSuccess(result: result, resp: response) + } + + // Create an authenticated search client + private func newSearchClientWithAuth(_ call: FlutterMethodCall, result: @escaping FlutterResult) { + let args = call.arguments as? [String: Any] ?? [String: Any]() + let apiBase = args["apiBase"] as? String ?? "" + guard let seedData = args["seed"] as? FlutterStandardTypedData else { + self.resultError(result: result, code: "INVALID_SEED", message: "Seed must be provided") + return + } + + let seed = seedData.data + if seed.count != 32 { + self.resultError(result: result, code: "INVALID_SEED", message: "Seed must be exactly 32 bytes") + return + } + + var error: NSError? + guard let client = SearchNewSearchClientWithAuth(apiBase, seed, &error) else { + self.resultError(result: result, error: error, code: "CREATE_AUTH_CLIENT_FAILED") + return + } + + // Generate unique ID for this client + let clientId = UUID().uuidString + + clientsLock.lock() + clients[clientId] = client + clientsLock.unlock() + + let response: [String: Any] = [ + "clientId": clientId + ] + + self.resultSuccess(result: result, resp: response) + } + + // Query data by keyword + private func query(_ call: FlutterMethodCall, result: @escaping FlutterResult) { + let args = call.arguments as? [String: Any] ?? [String: Any]() + let clientId = args["clientId"] as? String ?? "" + let keyword = args["keyword"] as? String ?? "" + + clientsLock.lock() + guard let client = clients[clientId] else { + clientsLock.unlock() + self.resultError(result: result, code: "CLIENT_NOT_FOUND", message: "Search client not found") + return + } + clientsLock.unlock() + + searchQueue.async { + var error: NSError? + let response = client.query(keyword, error: &error) + + if let error = error { + self.resultError(result: result, error: error, code: "QUERY_FAILED") + return + } + + self.resultSuccess(result: result, resp: response) + } + } + + // Submit user data + private func submitUserData(_ call: FlutterMethodCall, result: @escaping FlutterResult) { + let args = call.arguments as? [String: Any] ?? [String: Any]() + let clientId = args["clientId"] as? String ?? "" + var nknAddress = args["nknAddress"] as? String ?? "" + let customId = args["customId"] as? String ?? "" + let nickname = args["nickname"] as? String ?? "" + let phoneNumber = args["phoneNumber"] as? String ?? "" + + clientsLock.lock() + guard let client = clients[clientId] else { + clientsLock.unlock() + self.resultError(result: result, code: "CLIENT_NOT_FOUND", message: "Search client not found") + return + } + clientsLock.unlock() + + // Use high-priority queue for CPU-intensive PoW calculation + let queueItem = DispatchWorkItem { + // Process nknAddress: if empty, use publicKey + let publicKeyHex = client.getPublicKeyHex() + + if nknAddress.isEmpty { + nknAddress = publicKeyHex + } else { + // Validate format if contains dot + if nknAddress.contains(".") { + let parts = nknAddress.components(separatedBy: ".") + if parts.count != 2 { + self.resultError(result: result, code: "INVALID_PARAMETER", + message: "Invalid nknAddress format. Expected: identifier.publickey") + return + } + let providedPubKey = parts[1] + if providedPubKey.lowercased() != publicKeyHex.lowercased() { + self.resultError(result: result, code: "INVALID_PARAMETER", + message: "nknAddress publickey suffix must match your actual publicKey") + return + } + } else { + // If no dot, must equal publicKey + if nknAddress.lowercased() != publicKeyHex.lowercased() { + self.resultError(result: result, code: "INVALID_PARAMETER", + message: "nknAddress must be either \"identifier.publickey\" format or equal to publicKey") + return + } + } + } + + // Validate customId if provided + if !customId.isEmpty && customId.count < 3 { + self.resultError(result: result, code: "INVALID_PARAMETER", + message: "customId must be at least 3 characters if provided") + return + } + + do { + // PoW calculation happens here - runs on high priority background thread + try client.submitUserData(nknAddress, customId: customId, nickname: nickname, phoneNumber: phoneNumber) + self.resultSuccess(result: result, resp: ["success": true]) + } catch let error as NSError { + self.resultError(result: result, error: error, code: "SUBMIT_FAILED") + } + } + powQueue.async(execute: queueItem) + } + + // Verify the client (optional, for query operations) + private func verify(_ call: FlutterMethodCall, result: @escaping FlutterResult) { + let args = call.arguments as? [String: Any] ?? [String: Any]() + let clientId = args["clientId"] as? String ?? "" + + clientsLock.lock() + guard let client = clients[clientId] else { + clientsLock.unlock() + self.resultError(result: result, code: "CLIENT_NOT_FOUND", message: "Search client not found") + return + } + clientsLock.unlock() + + // Use high-priority queue for CPU-intensive PoW calculation + powQueue.async { + do { + // PoW calculation happens here - runs on high priority background thread + try client.verify() + self.resultSuccess(result: result, resp: ["success": true]) + } catch let error as NSError { + self.resultError(result: result, error: error, code: "VERIFY_FAILED") + } + } + } + + // Query by ID + private func queryByID(_ call: FlutterMethodCall, result: @escaping FlutterResult) { + let args = call.arguments as? [String: Any] ?? [String: Any]() + let clientId = args["clientId"] as? String ?? "" + let id = args["id"] as? String ?? "" + + clientsLock.lock() + guard let client = clients[clientId] else { + clientsLock.unlock() + self.resultError(result: result, code: "CLIENT_NOT_FOUND", message: "Search client not found") + return + } + clientsLock.unlock() + + searchQueue.async { + var error: NSError? + let response = client.query(byID: id, error: &error) + + if let error = error { + self.resultError(result: result, error: error, code: "QUERY_BY_ID_FAILED") + return + } + + self.resultSuccess(result: result, resp: response) + } + } + + // Get my info by nknAddress + private func getMyInfo(_ call: FlutterMethodCall, result: @escaping FlutterResult) { + let args = call.arguments as? [String: Any] ?? [String: Any]() + let clientId = args["clientId"] as? String ?? "" + let address = args["address"] as? String ?? "" + + clientsLock.lock() + guard let client = clients[clientId] else { + clientsLock.unlock() + self.resultError(result: result, code: "CLIENT_NOT_FOUND", message: "Search client not found") + return + } + clientsLock.unlock() + + searchQueue.async { + var error: NSError? + let response = client.getMyInfo(address, error: &error) + + if let error = error { + self.resultError(result: result, error: error, code: "GET_MY_INFO_FAILED") + return + } + + self.resultSuccess(result: result, resp: response) + } + } + + // Get public key hex + private func getPublicKeyHex(_ call: FlutterMethodCall, result: @escaping FlutterResult) { + let args = call.arguments as? [String: Any] ?? [String: Any]() + let clientId = args["clientId"] as? String ?? "" + + clientsLock.lock() + guard let client = clients[clientId] else { + clientsLock.unlock() + self.resultError(result: result, code: "CLIENT_NOT_FOUND", message: "Search client not found") + return + } + clientsLock.unlock() + + let publicKeyHex = client.getPublicKeyHex() + self.resultSuccess(result: result, resp: publicKeyHex) + } + + // Get wallet address + private func getAddress(_ call: FlutterMethodCall, result: @escaping FlutterResult) { + let args = call.arguments as? [String: Any] ?? [String: Any]() + let clientId = args["clientId"] as? String ?? "" + + clientsLock.lock() + guard let client = clients[clientId] else { + clientsLock.unlock() + self.resultError(result: result, code: "CLIENT_NOT_FOUND", message: "Search client not found") + return + } + clientsLock.unlock() + + let address = client.getAddress() + self.resultSuccess(result: result, resp: address) + } + + // Check if verified + private func isVerified(_ call: FlutterMethodCall, result: @escaping FlutterResult) { + let args = call.arguments as? [String: Any] ?? [String: Any]() + let clientId = args["clientId"] as? String ?? "" + + clientsLock.lock() + guard let client = clients[clientId] else { + clientsLock.unlock() + self.resultError(result: result, code: "CLIENT_NOT_FOUND", message: "Search client not found") + return + } + clientsLock.unlock() + + let verified = client.isVerified() + self.resultSuccess(result: result, resp: verified) + } + + // Dispose client + private func disposeClient(_ call: FlutterMethodCall, result: @escaping FlutterResult) { + let args = call.arguments as? [String: Any] ?? [String: Any]() + let clientId = args["clientId"] as? String ?? "" + + clientsLock.lock() + clients.removeValue(forKey: clientId) + clientsLock.unlock() + + self.resultSuccess(result: result, resp: ["success": true]) + } +} diff --git a/ios/Podfile.lock b/ios/Podfile.lock index 70f06ffb7..0a23c428d 100644 --- a/ios/Podfile.lock +++ b/ios/Podfile.lock @@ -409,7 +409,7 @@ SPEC CHECKSUMS: Mantle: c5aa8794a29a022dfbbfc9799af95f477a69b62d MTBBarcodeScanner: f453b33c4b7dfe545d8c6484ed744d55671788cb nanopb: fad817b59e0457d11a5dfbde799381cd727c1275 - nkn_sdk_flutter: 58f1b078e197b86b921cd0a93a12504a28714462 + nkn_sdk_flutter: 5f3fb21d48cd76f5d7be35851b9b03b1a18080e2 open_filex: 432f3cd11432da3e39f47fcc0df2b1603854eff1 package_info_plus: af8e2ca6888548050f16fa2f1938db7b5a5df499 path_provider_foundation: 080d55be775b7414fd5a5ef3ac137b97b097e564 diff --git a/ios/Runner.xcodeproj/project.pbxproj b/ios/Runner.xcodeproj/project.pbxproj index 7870a259a..f708566a1 100644 --- a/ios/Runner.xcodeproj/project.pbxproj +++ b/ios/Runner.xcodeproj/project.pbxproj @@ -9,6 +9,7 @@ /* Begin PBXBuildFile section */ 147F890F2E83CBC000BFD48B /* ShareExtension.appex in Embed App Extensions */ = {isa = PBXBuildFile; fileRef = 147F89052E83CBC000BFD48B /* ShareExtension.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; }; 147F899F2EA0F29300BFD48B /* Config.generated.swift in Sources */ = {isa = PBXBuildFile; fileRef = 147F899E2EA0F29300BFD48B /* Config.generated.swift */; }; + 147F89C22EAB685B00BFD48B /* Search.swift in Sources */ = {isa = PBXBuildFile; fileRef = 147F89C12EAB684F00BFD48B /* Search.swift */; }; 148FFB1A2E60499C005410D0 /* GoogleService-Info.plist in Resources */ = {isa = PBXBuildFile; fileRef = 148FFB192E60499C005410D0 /* GoogleService-Info.plist */; }; 1498D2341E8E89220040F4C2 /* GeneratedPluginRegistrant.m in Sources */ = {isa = PBXBuildFile; fileRef = 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */; }; 149BC80728FEAEB000D27A6D /* DnsResolver.swift in Sources */ = {isa = PBXBuildFile; fileRef = 149BC80628FEAEB000D27A6D /* DnsResolver.swift */; }; @@ -84,6 +85,9 @@ 142209AC2E7AB14500C679DC /* receive_sharing_intent.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; path = receive_sharing_intent.framework; sourceTree = BUILT_PRODUCTS_DIR; }; 147F89052E83CBC000BFD48B /* ShareExtension.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = ShareExtension.appex; sourceTree = BUILT_PRODUCTS_DIR; }; 147F899E2EA0F29300BFD48B /* Config.generated.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Config.generated.swift; sourceTree = ""; }; + 147F89C12EAB684F00BFD48B /* Search.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Search.swift; sourceTree = ""; }; + 147F8A132EAF5B4000BFD48B /* libresolv.tbd */ = {isa = PBXFileReference; lastKnownFileType = "sourcecode.text-based-dylib-definition"; name = libresolv.tbd; path = usr/lib/libresolv.tbd; sourceTree = SDKROOT; }; + 147F8A252EAF5BE400BFD48B /* libresolv.9.tbd */ = {isa = PBXFileReference; lastKnownFileType = "sourcecode.text-based-dylib-definition"; name = libresolv.9.tbd; path = usr/lib/libresolv.9.tbd; sourceTree = SDKROOT; }; 148FFB192E60499C005410D0 /* GoogleService-Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = "GoogleService-Info.plist"; sourceTree = ""; }; 1498D2321E8E86230040F4C2 /* GeneratedPluginRegistrant.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = GeneratedPluginRegistrant.h; sourceTree = ""; }; 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = GeneratedPluginRegistrant.m; sourceTree = ""; }; @@ -186,9 +190,18 @@ /* End PBXFrameworksBuildPhase section */ /* Begin PBXGroup section */ + 147F89C02EAB684700BFD48B /* SearchService */ = { + isa = PBXGroup; + children = ( + 147F89C12EAB684F00BFD48B /* Search.swift */, + ); + path = SearchService; + sourceTree = ""; + }; 149BC80528FD5E9300D27A6D /* impl */ = { isa = PBXGroup; children = ( + 147F89C02EAB684700BFD48B /* SearchService */, 14A871CC287D6D980093692A /* NameService */, ); path = impl; @@ -222,6 +235,8 @@ 594E66BF7E7D2CAB5FC62094 /* Frameworks */ = { isa = PBXGroup; children = ( + 147F8A252EAF5BE400BFD48B /* libresolv.9.tbd */, + 147F8A132EAF5B4000BFD48B /* libresolv.tbd */, 142209AC2E7AB14500C679DC /* receive_sharing_intent.framework */, 334D81B7E27D6131B561C943 /* Pods_Runner.framework */, 9C257DECA1497057748992AF /* Pods_Share_Extension.framework */, @@ -629,6 +644,7 @@ 1498D2341E8E89220040F4C2 /* GeneratedPluginRegistrant.m in Sources */, C62BB8162689AA0E00DAAC43 /* NWPushFeedback.m in Sources */, C62BB81A2689AA0E00DAAC43 /* NWPusher.m in Sources */, + 147F89C22EAB685B00BFD48B /* Search.swift in Sources */, C6824D29272E4EA900BA7AAF /* Connection.swift in Sources */, 149BC80728FEAEB000D27A6D /* DnsResolver.swift in Sources */, ); diff --git a/ios/Runner/AppDelegate.swift b/ios/Runner/AppDelegate.swift index 01b35a855..b34634053 100644 --- a/ios/Runner/AppDelegate.swift +++ b/ios/Runner/AppDelegate.swift @@ -25,6 +25,7 @@ import Firebase Crypto.register(controller: controller) EthResolver.register(controller: controller) DnsResolver.register(controller: controller) + SearchService.register(controller: controller) registerNotification(); diff --git a/lib/common/search_service/search_service.dart b/lib/common/search_service/search_service.dart new file mode 100644 index 000000000..140afc133 --- /dev/null +++ b/lib/common/search_service/search_service.dart @@ -0,0 +1,132 @@ +import 'dart:typed_data'; + +import 'package:flutter_dotenv/flutter_dotenv.dart'; + +import '../../native/search.dart'; + +class SearchService { + static String _apiBase = dotenv.get('NAME_SERVICE_API_BASE'); + + final SearchClient _client; + + SearchService._(this._client); + + /// Create a query-only search service (no authentication required) + static Future create() async { + final client = await SearchClient.create(apiBase: _apiBase); + return SearchService._(client); + } + + /// Create an authenticated search service + static Future createWithAuth({required Uint8List seed}) async { + final client = await SearchClient.createWithAuth( + apiBase: _apiBase, + seed: seed, + ); + return SearchService._(client); + } + + /// Query by keyword + Future> query(String keyword) async { + return await _client.query(keyword); + } + + /// Submit or update user data (requires authentication) + /// + /// Parameters: + /// - nknAddress: NKN client address (optional, format: "identifier.publickey" or just publickey) + /// If empty, defaults to publickey. Must be either "identifier.publickey" format or equal to publickey. + /// - customId: Custom identifier (optional, min 3 characters if provided) + /// - nickname: User nickname (optional) + /// - phoneNumber: Phone number (optional) + /// + /// If the publicKey already exists, this will UPDATE the user data. + /// + /// Examples: + /// ```dart + /// // Option 1: Use default (empty - will use publickey) + /// await service.submitUserData(nickname: 'Alice'); + /// + /// // Option 2: Use publickey directly + /// final pubKey = await service.getPublicKeyHex(); + /// await service.submitUserData(nknAddress: pubKey, nickname: 'Alice'); + /// + /// // Option 3: Use custom identifier.publickey format + /// await service.submitUserData( + /// nknAddress: 'alice.${await service.getPublicKeyHex()}', + /// customId: 'user123', + /// nickname: 'Alice', + /// ); + /// ``` + Future submitUserData({ + String? nknAddress, + String? customId, + String? nickname, + String? phoneNumber, + }) async { + await _client.submitUserData( + nknAddress: nknAddress, + customId: customId, + nickname: nickname, + phoneNumber: phoneNumber, + ); + } + + /// Query by ID (requires authentication and verification) + Future queryByID(String id) async { + return await _client.queryByID(id); + } + + /// Verify the client + Future verify() async { + await _client.verify(); + } + + /// Check if client is verified + Future isVerified() async { + return await _client.isVerified(); + } + + /// Get public key + Future getPublicKeyHex() async { + return await _client.getPublicKeyHex(); + } + + /// Get wallet address + Future getAddress() async { + return await _client.getAddress(); + } + + /// Get my own information by querying with nknAddress + /// + /// Parameters: + /// - nknAddress: (Optional) The NKN address to query. If not provided, uses the authenticated client's publickey. + /// Can be in "identifier.publickey" format or just the publickey. + /// + /// Returns the user's information if found, or null if not found. + /// + /// Example: + /// ```dart + /// // Query using the authenticated client's publickey (recommended) + /// final myInfo = await service.getMyInfo(); + /// + /// // Or query with a specific nknAddress + /// final pubKey = await service.getPublicKeyHex(); + /// final myInfo = await service.getMyInfo(nknAddress: pubKey); + /// + /// // Or with identifier.publickey format + /// final myInfo = await service.getMyInfo(nknAddress: 'alice.$pubKey'); + /// ``` + Future getMyInfo({String? nknAddress}) async { + // If nknAddress not provided, get the public key from the authenticated client + final address = nknAddress ?? await _client.getPublicKeyHex(); + + // Call the native getMyInfo method + return await _client.getMyInfo(address); + } + + /// Dispose the client and free resources + Future dispose() async { + await _client.dispose(); + } +} diff --git a/lib/components/dialog/bottom.dart b/lib/components/dialog/bottom.dart index bd56e13d7..e849fc653 100644 --- a/lib/components/dialog/bottom.dart +++ b/lib/components/dialog/bottom.dart @@ -290,15 +290,18 @@ class BottomDialog extends BaseStateFulWidget { String? actionText, bool password = false, double height = 300, + int minLength = 0, int maxLength = 10000, bool enable = true, bool contactSelect = false, bool canTapClose = true, + Future Function(String)? asyncValidator, }) async { TextEditingController _inputController = TextEditingController(); _inputController.text = value ?? ""; + ValueNotifier errorNotifier = ValueNotifier(null); - return showWithTitle( + return showWithTitle( title: title, desc: desc, height: height, @@ -308,7 +311,22 @@ class BottomDialog extends BaseStateFulWidget { child: Button( text: actionText ?? Settings.locale((s) => s.continue_text, ctx: context), width: double.infinity, - onPressed: () { + onPressed: () async { + // Validate minimum length + if (minLength > 0 && _inputController.text.length < minLength) { + errorNotifier.value = Settings.locale((s) => s.tip_input_min_length(minLength.toString()), ctx: context); + return; + } + + // Async validation + if (asyncValidator != null) { + final error = await asyncValidator(_inputController.text); + if (error != null) { + errorNotifier.value = error; + return; + } + } + if (Navigator.of(this.context).canPop()) Navigator.pop(this.context, _inputController.text); }, ), @@ -323,32 +341,44 @@ class BottomDialog extends BaseStateFulWidget { type: LabelType.h4, textAlign: TextAlign.start, ), - FormText( - controller: _inputController, - hintText: inputHint ?? "", - validator: validator, - password: password, - maxLength: maxLength, - enabled: enable, - suffixIcon: contactSelect - ? GestureDetector( - onTap: () async { - if (clientCommon.isClientOK) { - var contact = await ContactHomeScreen.go(context, selectContact: true); - if ((contact != null) && (contact is ContactSchema)) { - _inputController.text = contact.address; - } - } else { - Toast.show(Settings.locale((s) => s.d_chat_not_login, ctx: context)); - } - }, - child: Container( - width: 20, - alignment: Alignment.centerRight, - child: Icon(FontAwesomeIcons.solidAddressBook), - ), - ) - : SizedBox.shrink(), + ValueListenableBuilder( + valueListenable: errorNotifier, + builder: (context, errorText, child) { + return FormText( + controller: _inputController, + hintText: inputHint ?? "", + validator: validator, + password: password, + maxLength: maxLength, + enabled: enable, + errorText: errorText, + onChanged: (value) { + // Clear error when user types + if (errorNotifier.value != null) { + errorNotifier.value = null; + } + }, + suffixIcon: contactSelect + ? GestureDetector( + onTap: () async { + if (clientCommon.isClientOK) { + var contact = await ContactHomeScreen.go(context, selectContact: true); + if ((contact != null) && (contact is ContactSchema)) { + _inputController.text = contact.address; + } + } else { + Toast.show(Settings.locale((s) => s.d_chat_not_login, ctx: context)); + } + }, + child: Container( + width: 20, + alignment: Alignment.centerRight, + child: Icon(FontAwesomeIcons.solidAddressBook), + ), + ) + : SizedBox.shrink(), + ); + }, ), ], ), diff --git a/lib/components/text/form_text.dart b/lib/components/text/form_text.dart index ee961d7b3..4d04e922d 100644 --- a/lib/components/text/form_text.dart +++ b/lib/components/text/form_text.dart @@ -33,6 +33,7 @@ class FormText extends BaseStateFulWidget { // decoration String? hintText; String? helperText; + String? errorText; final bool showErrorMessage; Widget? suffixIcon; final Color? borderColor; @@ -64,6 +65,7 @@ class FormText extends BaseStateFulWidget { // decoration this.hintText, this.helperText, + this.errorText, this.showErrorMessage = true, this.suffixIcon, this.borderColor, @@ -117,6 +119,8 @@ class _FormTextState extends BaseStateFulWidgetState { hintStyle: TextStyle(fontSize: widget.fontSize), helperText: widget.helperText, helperMaxLines: 3, + errorText: widget.errorText, + errorMaxLines: 3, errorStyle: widget.showErrorMessage ? null : TextStyle(height: 0, fontSize: 0), suffixIcon: GestureDetector( onTap: () => setState(() => _showPassword = !_showPassword), @@ -156,6 +160,8 @@ class _FormTextState extends BaseStateFulWidgetState { hintText: widget.hintText, hintStyle: TextStyle(fontSize: widget.fontSize), helperText: widget.helperText, + errorText: widget.errorText, + errorMaxLines: 3, errorStyle: widget.showErrorMessage ? null : TextStyle(height: 0, fontSize: 0), suffixIcon: widget.suffixIcon, disabledBorder: borderStyle, diff --git a/lib/generated/intl/messages_en.dart b/lib/generated/intl/messages_en.dart index ea36f4ea0..05c701d30 100644 --- a/lib/generated/intl/messages_en.dart +++ b/lib/generated/intl/messages_en.dart @@ -33,6 +33,8 @@ class MessageLookup extends MessageLookupByLibrary { static String m5(other) => "${other} have already accepted"; + static String m6(length) => "Please enter at least ${length} characters."; + final messages = _notInlinedMessages(_notInlinedMessages); static Map _notInlinedMessages(_) => { "Accelerate": MessageLookupByLibrary.simpleMessage("Accelerate"), @@ -182,6 +184,9 @@ class MessageLookup extends MessageLookupByLibrary { "create_private_group": MessageLookupByLibrary.simpleMessage("Create Private Group"), "create_wallet": MessageLookupByLibrary.simpleMessage("Create Account"), + "custom_id": MessageLookupByLibrary.simpleMessage("Custom ID"), + "custom_id_tips": MessageLookupByLibrary.simpleMessage( + "You can set a custom ID so that others can add you as a friend using this ID."), "d_chat": MessageLookupByLibrary.simpleMessage("D-Chat"), "d_chat_address": MessageLookupByLibrary.simpleMessage("D-Chat ID"), "d_chat_not_login": @@ -233,6 +238,8 @@ class MessageLookup extends MessageLookupByLibrary { "done": MessageLookupByLibrary.simpleMessage("Done"), "edit": MessageLookupByLibrary.simpleMessage("Edit"), "edit_contact": MessageLookupByLibrary.simpleMessage("Edit Contact"), + "edit_custom_id": + MessageLookupByLibrary.simpleMessage("Edit Custom ID"), "edit_name": MessageLookupByLibrary.simpleMessage("Edit Name"), "edit_nickname": MessageLookupByLibrary.simpleMessage("Edit Nickname"), "edit_notes": MessageLookupByLibrary.simpleMessage("Edit Notes"), @@ -294,6 +301,7 @@ class MessageLookup extends MessageLookupByLibrary { "gas_price": MessageLookupByLibrary.simpleMessage("Gas Price"), "general": MessageLookupByLibrary.simpleMessage("General"), "go_backup": MessageLookupByLibrary.simpleMessage("Go Backup"), + "go_to_set": MessageLookupByLibrary.simpleMessage("Go to set"), "group_chat": MessageLookupByLibrary.simpleMessage("Group"), "group_member_already": MessageLookupByLibrary.simpleMessage( "The member is in group already"), @@ -324,6 +332,8 @@ class MessageLookup extends MessageLookupByLibrary { "From your existing wallet, find out how to export Seed (also called \"Secret Seed\"), make a backup copy, and then use it to import your existing wallet into nMobile."), "import_with_seed_title": MessageLookupByLibrary.simpleMessage("Import with Seed"), + "input_custom_id": + MessageLookupByLibrary.simpleMessage("Please input Custom ID"), "input_d_chat_address": MessageLookupByLibrary.simpleMessage("Please input D-Chat ID"), "input_keystore": @@ -553,6 +563,7 @@ class MessageLookup extends MessageLookupByLibrary { "start_chat": MessageLookupByLibrary.simpleMessage("Start Chat"), "storage_text": MessageLookupByLibrary.simpleMessage("Storage"), "stranger": MessageLookupByLibrary.simpleMessage("Stranger"), + "submitting": MessageLookupByLibrary.simpleMessage("Submitting..."), "subscribe": MessageLookupByLibrary.simpleMessage("Subscribe"), "subscribe_or_waiting": MessageLookupByLibrary.simpleMessage("Subscribe or Waiting..."), @@ -565,10 +576,19 @@ class MessageLookup extends MessageLookupByLibrary { "tip": MessageLookupByLibrary.simpleMessage("Tips"), "tip_ask_group_owner_permission": MessageLookupByLibrary.simpleMessage( "You are not in this group,ask the group owner for permission"), + "tip_custom_id_already_set": MessageLookupByLibrary.simpleMessage( + "This custom ID is already set as your current ID"), + "tip_custom_id_format": MessageLookupByLibrary.simpleMessage( + "Custom ID can only contain letters, numbers, or underscores"), + "tip_custom_id_taken": MessageLookupByLibrary.simpleMessage( + "This custom ID is already taken by another user"), + "tip_input_min_length": m6, "tip_open_send_device_token": MessageLookupByLibrary.simpleMessage( "Whether to open the notification reminder from the other party?"), "tip_password_error": MessageLookupByLibrary.simpleMessage("Wrong password"), + "tip_submit_failed": + MessageLookupByLibrary.simpleMessage("Submit failed"), "tip_switch_success": MessageLookupByLibrary.simpleMessage("Switch Success!"), "tips": MessageLookupByLibrary.simpleMessage("Tips"), @@ -620,6 +640,8 @@ class MessageLookup extends MessageLookupByLibrary { "video": MessageLookupByLibrary.simpleMessage("Video"), "view_all": MessageLookupByLibrary.simpleMessage("View All"), "view_channel_members": MessageLookupByLibrary.simpleMessage("Members"), + "view_more_info": + MessageLookupByLibrary.simpleMessage("View More Info"), "view_profile": MessageLookupByLibrary.simpleMessage("View Profile"), "view_qrcode": MessageLookupByLibrary.simpleMessage("View QR Code"), "waiting_for_data_to_sync": diff --git a/lib/generated/intl/messages_zh_CN.dart b/lib/generated/intl/messages_zh_CN.dart index e4c69e127..0c03b4562 100644 --- a/lib/generated/intl/messages_zh_CN.dart +++ b/lib/generated/intl/messages_zh_CN.dart @@ -32,6 +32,8 @@ class MessageLookup extends MessageLookupByLibrary { static String m5(other) => "${other}已接受邀请"; + static String m6(length) => "请至少输入 ${length} 个字符。"; + final messages = _notInlinedMessages(_notInlinedMessages); static Map _notInlinedMessages(_) => { "Accelerate": MessageLookupByLibrary.simpleMessage("加速"), @@ -153,6 +155,9 @@ class MessageLookup extends MessageLookupByLibrary { "create_nkn_wallet": MessageLookupByLibrary.simpleMessage("创建主网账户"), "create_private_group": MessageLookupByLibrary.simpleMessage("新建私有群"), "create_wallet": MessageLookupByLibrary.simpleMessage("创建账户"), + "custom_id": MessageLookupByLibrary.simpleMessage("自定义ID"), + "custom_id_tips": MessageLookupByLibrary.simpleMessage( + "您可以设置自定义ID,以便于其他人通过此ID添加您为好友。"), "d_chat": MessageLookupByLibrary.simpleMessage("滴聊"), "d_chat_address": MessageLookupByLibrary.simpleMessage("滴聊地址"), "d_chat_not_login": MessageLookupByLibrary.simpleMessage("D-Chat 未登录"), @@ -196,6 +201,7 @@ class MessageLookup extends MessageLookupByLibrary { "done": MessageLookupByLibrary.simpleMessage("完成"), "edit": MessageLookupByLibrary.simpleMessage("编辑"), "edit_contact": MessageLookupByLibrary.simpleMessage("编辑"), + "edit_custom_id": MessageLookupByLibrary.simpleMessage("编辑自定义ID"), "edit_name": MessageLookupByLibrary.simpleMessage("编辑"), "edit_nickname": MessageLookupByLibrary.simpleMessage("编辑昵称"), "edit_notes": MessageLookupByLibrary.simpleMessage("编辑备注"), @@ -247,6 +253,7 @@ class MessageLookup extends MessageLookupByLibrary { "gas_price": MessageLookupByLibrary.simpleMessage("Gas Price"), "general": MessageLookupByLibrary.simpleMessage("通用"), "go_backup": MessageLookupByLibrary.simpleMessage("去备份"), + "go_to_set": MessageLookupByLibrary.simpleMessage("去设置"), "group_chat": MessageLookupByLibrary.simpleMessage("群"), "group_member_already": MessageLookupByLibrary.simpleMessage("该成员已经加入本群"), @@ -272,6 +279,7 @@ class MessageLookup extends MessageLookupByLibrary { "从已有的账户中,找出如何导出Seed(也称为“秘密种子”),对其进行备份,然后使用它导入nMobile。"), "import_with_seed_title": MessageLookupByLibrary.simpleMessage("使用Seed导入"), + "input_custom_id": MessageLookupByLibrary.simpleMessage("请输入自定义ID"), "input_d_chat_address": MessageLookupByLibrary.simpleMessage("请输入滴聊地址"), "input_keystore": MessageLookupByLibrary.simpleMessage("请粘贴 keystore"), "input_name": MessageLookupByLibrary.simpleMessage("请输入名字"), @@ -462,6 +470,7 @@ class MessageLookup extends MessageLookupByLibrary { "start_chat": MessageLookupByLibrary.simpleMessage("开始聊天"), "storage_text": MessageLookupByLibrary.simpleMessage("存储"), "stranger": MessageLookupByLibrary.simpleMessage("陌生人"), + "submitting": MessageLookupByLibrary.simpleMessage("提交中..."), "subscribe": MessageLookupByLibrary.simpleMessage("加入"), "subscribe_or_waiting": MessageLookupByLibrary.simpleMessage("加入或等待片刻..."), @@ -474,9 +483,17 @@ class MessageLookup extends MessageLookupByLibrary { "tip": MessageLookupByLibrary.simpleMessage("提示"), "tip_ask_group_owner_permission": MessageLookupByLibrary.simpleMessage("您已不在此群,联系群主邀请您"), + "tip_custom_id_already_set": + MessageLookupByLibrary.simpleMessage("此自定义ID已经是您当前的ID,无需重复设置"), + "tip_custom_id_format": + MessageLookupByLibrary.simpleMessage("自定义ID只能包含字母、数字或下划线"), + "tip_custom_id_taken": + MessageLookupByLibrary.simpleMessage("此自定义ID已被其他用户占用"), + "tip_input_min_length": m6, "tip_open_send_device_token": MessageLookupByLibrary.simpleMessage("是否开启来自对方的消息通知提醒?"), "tip_password_error": MessageLookupByLibrary.simpleMessage("密码错误"), + "tip_submit_failed": MessageLookupByLibrary.simpleMessage("提交失败"), "tip_switch_success": MessageLookupByLibrary.simpleMessage("切换成功!"), "tips": MessageLookupByLibrary.simpleMessage("提示"), "title": MessageLookupByLibrary.simpleMessage("nMobile"), @@ -523,6 +540,7 @@ class MessageLookup extends MessageLookupByLibrary { "video": MessageLookupByLibrary.simpleMessage("视频"), "view_all": MessageLookupByLibrary.simpleMessage("查看全部"), "view_channel_members": MessageLookupByLibrary.simpleMessage("查看群成员"), + "view_more_info": MessageLookupByLibrary.simpleMessage("查看更多信息"), "view_profile": MessageLookupByLibrary.simpleMessage("查看资料"), "view_qrcode": MessageLookupByLibrary.simpleMessage("查看二维码"), "waiting_for_data_to_sync": diff --git a/lib/generated/intl/messages_zh_TW.dart b/lib/generated/intl/messages_zh_TW.dart index 8ac3f474b..d71a6efad 100644 --- a/lib/generated/intl/messages_zh_TW.dart +++ b/lib/generated/intl/messages_zh_TW.dart @@ -32,6 +32,8 @@ class MessageLookup extends MessageLookupByLibrary { static String m5(other) => "${other}已接受邀請"; + static String m6(length) => "請至少輸入 ${length} 個字符。"; + final messages = _notInlinedMessages(_notInlinedMessages); static Map _notInlinedMessages(_) => { "Accelerate": MessageLookupByLibrary.simpleMessage("加速"), @@ -153,6 +155,9 @@ class MessageLookup extends MessageLookupByLibrary { "create_nkn_wallet": MessageLookupByLibrary.simpleMessage("創建主網帳戶"), "create_private_group": MessageLookupByLibrary.simpleMessage("新建私有群"), "create_wallet": MessageLookupByLibrary.simpleMessage("創建賬戶"), + "custom_id": MessageLookupByLibrary.simpleMessage("自定義ID"), + "custom_id_tips": + MessageLookupByLibrary.simpleMessage("您可以設定自訂ID,以便其他人透過此ID新增您為好友。"), "d_chat": MessageLookupByLibrary.simpleMessage("滴聊"), "d_chat_address": MessageLookupByLibrary.simpleMessage("滴聊地址"), "d_chat_not_login": MessageLookupByLibrary.simpleMessage("D-Chat 未登錄"), @@ -196,6 +201,7 @@ class MessageLookup extends MessageLookupByLibrary { "done": MessageLookupByLibrary.simpleMessage("完成"), "edit": MessageLookupByLibrary.simpleMessage("編輯"), "edit_contact": MessageLookupByLibrary.simpleMessage("編輯"), + "edit_custom_id": MessageLookupByLibrary.simpleMessage("編輯自定義ID"), "edit_name": MessageLookupByLibrary.simpleMessage("編輯"), "edit_nickname": MessageLookupByLibrary.simpleMessage("編輯暱稱"), "edit_notes": MessageLookupByLibrary.simpleMessage("編輯備註"), @@ -247,6 +253,7 @@ class MessageLookup extends MessageLookupByLibrary { "gas_price": MessageLookupByLibrary.simpleMessage("Gas Price"), "general": MessageLookupByLibrary.simpleMessage("通用"), "go_backup": MessageLookupByLibrary.simpleMessage("去備份"), + "go_to_set": MessageLookupByLibrary.simpleMessage("去設置"), "group_chat": MessageLookupByLibrary.simpleMessage("群"), "group_member_already": MessageLookupByLibrary.simpleMessage("該成員已經加入本群"), @@ -272,6 +279,7 @@ class MessageLookup extends MessageLookupByLibrary { "從已有的賬戶中,找出如何導出Seed(也稱為“秘密種子”),對其進行備份,然後使用它導入nMobile。"), "import_with_seed_title": MessageLookupByLibrary.simpleMessage("使用Seed導入"), + "input_custom_id": MessageLookupByLibrary.simpleMessage("請輸入自定義ID"), "input_d_chat_address": MessageLookupByLibrary.simpleMessage("請輸入滴聊地址"), "input_keystore": MessageLookupByLibrary.simpleMessage("請粘貼 keystore"), "input_name": MessageLookupByLibrary.simpleMessage("請輸入名字"), @@ -462,6 +470,7 @@ class MessageLookup extends MessageLookupByLibrary { "start_chat": MessageLookupByLibrary.simpleMessage("開始聊天"), "storage_text": MessageLookupByLibrary.simpleMessage("存儲"), "stranger": MessageLookupByLibrary.simpleMessage("陌生人"), + "submitting": MessageLookupByLibrary.simpleMessage("提交中..."), "subscribe": MessageLookupByLibrary.simpleMessage("加入"), "subscribe_or_waiting": MessageLookupByLibrary.simpleMessage("加入或等待片刻..."), @@ -474,9 +483,17 @@ class MessageLookup extends MessageLookupByLibrary { "tip": MessageLookupByLibrary.simpleMessage("提示"), "tip_ask_group_owner_permission": MessageLookupByLibrary.simpleMessage("您已不在此群,聯繫群主邀請您"), + "tip_custom_id_already_set": + MessageLookupByLibrary.simpleMessage("此自訂ID已經是您目前的ID,無需重複設定"), + "tip_custom_id_format": + MessageLookupByLibrary.simpleMessage("自訂ID只能包含字母、數字或底線"), + "tip_custom_id_taken": + MessageLookupByLibrary.simpleMessage("此自訂ID已被其他用戶佔用"), + "tip_input_min_length": m6, "tip_open_send_device_token": MessageLookupByLibrary.simpleMessage("是否開啟來自對方的消息通知提醒?"), "tip_password_error": MessageLookupByLibrary.simpleMessage("密碼錯誤"), + "tip_submit_failed": MessageLookupByLibrary.simpleMessage("提交失敗"), "tip_switch_success": MessageLookupByLibrary.simpleMessage("切換成功!"), "tips": MessageLookupByLibrary.simpleMessage("提示"), "title": MessageLookupByLibrary.simpleMessage("nMobile"), @@ -523,6 +540,7 @@ class MessageLookup extends MessageLookupByLibrary { "video": MessageLookupByLibrary.simpleMessage("視頻"), "view_all": MessageLookupByLibrary.simpleMessage("查看全部"), "view_channel_members": MessageLookupByLibrary.simpleMessage("查看群成員"), + "view_more_info": MessageLookupByLibrary.simpleMessage("查看更多資訊"), "view_profile": MessageLookupByLibrary.simpleMessage("查看資料"), "view_qrcode": MessageLookupByLibrary.simpleMessage("查看二維碼"), "waiting_for_data_to_sync": diff --git a/lib/generated/l10n.dart b/lib/generated/l10n.dart index 2d3a730cc..3de08fa33 100644 --- a/lib/generated/l10n.dart +++ b/lib/generated/l10n.dart @@ -2360,6 +2360,16 @@ class S { ); } + /// `Please input Custom ID` + String get input_custom_id { + return Intl.message( + 'Please input Custom ID', + name: 'input_custom_id', + desc: '', + args: [], + ); + } + /// `Invitee already exists` String get invitee_already_exists { return Intl.message( @@ -4139,6 +4149,116 @@ class S { args: [], ); } + + /// `Edit Custom ID` + String get edit_custom_id { + return Intl.message( + 'Edit Custom ID', + name: 'edit_custom_id', + desc: '', + args: [], + ); + } + + /// `Custom ID` + String get custom_id { + return Intl.message( + 'Custom ID', + name: 'custom_id', + desc: '', + args: [], + ); + } + + /// `You can set a custom ID so that others can add you as a friend using this ID.` + String get custom_id_tips { + return Intl.message( + 'You can set a custom ID so that others can add you as a friend using this ID.', + name: 'custom_id_tips', + desc: '', + args: [], + ); + } + + /// `Please enter at least {length} characters.` + String tip_input_min_length(Object length) { + return Intl.message( + 'Please enter at least $length characters.', + name: 'tip_input_min_length', + desc: '', + args: [length], + ); + } + + /// `Go to set` + String get go_to_set { + return Intl.message( + 'Go to set', + name: 'go_to_set', + desc: '', + args: [], + ); + } + + /// `Submitting...` + String get submitting { + return Intl.message( + 'Submitting...', + name: 'submitting', + desc: '', + args: [], + ); + } + + /// `Submit failed` + String get tip_submit_failed { + return Intl.message( + 'Submit failed', + name: 'tip_submit_failed', + desc: '', + args: [], + ); + } + + /// `This custom ID is already taken by another user` + String get tip_custom_id_taken { + return Intl.message( + 'This custom ID is already taken by another user', + name: 'tip_custom_id_taken', + desc: '', + args: [], + ); + } + + /// `This custom ID is already set as your current ID` + String get tip_custom_id_already_set { + return Intl.message( + 'This custom ID is already set as your current ID', + name: 'tip_custom_id_already_set', + desc: '', + args: [], + ); + } + + /// `View More Info` + String get view_more_info { + return Intl.message( + 'View More Info', + name: 'view_more_info', + desc: '', + args: [], + ); + } + + /// `Custom ID can only contain letters, numbers, or underscores` + String get tip_custom_id_format { + return Intl.message( + 'Custom ID can only contain letters, numbers, or underscores', + name: 'tip_custom_id_format', + desc: '', + args: [], + ); + } } class AppLocalizationDelegate extends LocalizationsDelegate { diff --git a/lib/l10n/intl_en.arb b/lib/l10n/intl_en.arb index d472d7d80..39865f299 100644 --- a/lib/l10n/intl_en.arb +++ b/lib/l10n/intl_en.arb @@ -250,6 +250,7 @@ "input_name": "Please input Name", "input_wallet_address": "Please input Account Address", "input_notes": "Please input Notes", + "input_custom_id": "Please input Custom ID", "invitee_already_exists": "Invitee already exists", "edit_notes": "Edit Notes", "view_all": "View All", @@ -427,5 +428,16 @@ "block": "Block", "block_tips": "After enabling, messages from this user will be blocked.", "revoke": "Revoke", - "confirm_revoke": "Are you sure you want to revoke this message?" + "confirm_revoke": "Are you sure you want to revoke this message?", + "edit_custom_id": "Edit Custom ID", + "custom_id": "Custom ID", + "custom_id_tips": "You can set a custom ID so that others can add you as a friend using this ID.", + "tip_input_min_length": "Please enter at least {length} characters.", + "go_to_set": "Go to set", + "submitting": "Submitting...", + "tip_submit_failed": "Submit failed", + "tip_custom_id_taken": "This custom ID is already taken by another user", + "tip_custom_id_already_set": "This custom ID is already set as your current ID", + "view_more_info": "View More Info", + "tip_custom_id_format": "Custom ID can only contain letters, numbers, or underscores" } \ No newline at end of file diff --git a/lib/l10n/intl_zh_CN.arb b/lib/l10n/intl_zh_CN.arb index 3317f7697..50989489a 100644 --- a/lib/l10n/intl_zh_CN.arb +++ b/lib/l10n/intl_zh_CN.arb @@ -250,6 +250,7 @@ "input_name": "请输入名字", "input_wallet_address": "请输入账户地址", "input_notes": "备注", + "input_custom_id": "请输入自定义ID", "invitee_already_exists": "收邀人已存在", "edit_notes": "编辑备注", "view_all": "查看全部", @@ -427,5 +428,16 @@ "block": "屏蔽", "block_tips": "启用后,将屏蔽对方的消息。", "revoke": "撤回", - "confirm_revoke": "确定要撤回此消息吗?" + "confirm_revoke": "确定要撤回此消息吗?", + "edit_custom_id": "编辑自定义ID", + "custom_id": "自定义ID", + "custom_id_tips": "您可以设置自定义ID,以便于其他人通过此ID添加您为好友。", + "tip_input_min_length": "请至少输入 {length} 个字符。", + "go_to_set": "去设置", + "submitting": "提交中...", + "tip_submit_failed": "提交失败", + "tip_custom_id_taken": "此自定义ID已被其他用户占用", + "tip_custom_id_already_set": "此自定义ID已经是您当前的ID,无需重复设置", + "view_more_info": "查看更多信息", + "tip_custom_id_format": "自定义ID只能包含字母、数字或下划线" } \ No newline at end of file diff --git a/lib/l10n/intl_zh_TW.arb b/lib/l10n/intl_zh_TW.arb index aa6ad1c45..e710884b7 100644 --- a/lib/l10n/intl_zh_TW.arb +++ b/lib/l10n/intl_zh_TW.arb @@ -250,6 +250,7 @@ "input_name": "請輸入名字", "input_wallet_address": "請輸入賬戶地址", "input_notes": "備註", + "input_custom_id": "請輸入自定義ID", "invitee_already_exists": "收邀人已存在", "edit_notes": "編輯備註", "view_all": "查看全部", @@ -427,5 +428,16 @@ "block": "屏蔽", "block_tips": "啟用後,將封鎖對方的訊息。", "revoke": "撤回", - "confirm_revoke": "確定要撤回此消息嗎?" + "confirm_revoke": "確定要撤回此消息嗎?", + "edit_custom_id": "編輯自定義ID", + "custom_id": "自定義ID", + "custom_id_tips": "您可以設定自訂ID,以便其他人透過此ID新增您為好友。", + "tip_input_min_length": "請至少輸入 {length} 個字符。", + "go_to_set": "去設置", + "submitting": "提交中...", + "tip_submit_failed": "提交失敗", + "tip_custom_id_taken": "此自訂ID已被其他用戶佔用", + "tip_custom_id_already_set": "此自訂ID已經是您目前的ID,無需重複設定", + "view_more_info": "查看更多資訊", + "tip_custom_id_format": "自訂ID只能包含字母、數字或底線" } \ No newline at end of file diff --git a/lib/native/search.dart b/lib/native/search.dart new file mode 100644 index 000000000..f007d878d --- /dev/null +++ b/lib/native/search.dart @@ -0,0 +1,569 @@ +import 'package:flutter/services.dart'; +import 'dart:convert'; + +/// NKN Search Client for Flutter +/// Provides search and profile management functionality via platform channels +class SearchClient { + static const MethodChannel _channel = MethodChannel('org.nkn.mobile/native/search'); + + String? _clientId; + final String apiBase; + final Uint8List? seed; + final bool isAuthMode; + + // Private constructor + SearchClient._({ + required this.apiBase, + this.seed, + required this.isAuthMode, + }); + + /// Create a query-only search client + /// Does not require authentication, only for querying public data + static Future create({ + required String apiBase, + }) async { + final client = SearchClient._( + apiBase: apiBase, + isAuthMode: false, + ); + await client._initialize(); + return client; + } + + /// Create an authenticated search client + /// Required for writing data and authenticated queries + /// + /// Parameters: + /// - apiBase: API server address (e.g., "https://search.nkn.org/api/v1") + /// - seed: NKN wallet seed (32 bytes) + static Future createWithAuth({ + required String apiBase, + required Uint8List seed, + }) async { + if (seed.length != 32) { + throw SearchException( + 'Seed must be exactly 32 bytes', + code: 'INVALID_SEED', + ); + } + + final client = SearchClient._( + apiBase: apiBase, + seed: seed, + isAuthMode: true, + ); + await client._initialize(); + return client; + } + + /// Initialize the native client + Future _initialize() async { + try { + if (isAuthMode) { + final result = await _channel.invokeMethod('newSearchClientWithAuth', { + 'apiBase': apiBase, + 'seed': seed, + }); + _clientId = result['clientId']; + } else { + final result = await _channel.invokeMethod('newSearchClient', { + 'apiBase': apiBase, + }); + _clientId = result['clientId']; + } + } on PlatformException catch (e) { + throw SearchException( + 'Failed to create search client: ${e.message}', + code: e.code, + ); + } + } + + /// Query data by keyword + /// Returns a list of search results + /// + /// Example: + /// ```dart + /// final results = await client.query('alice'); + /// for (var item in results) { + /// print('${item.customId}: ${item.nickname}'); + /// } + /// ``` + Future> query(String keyword) async { + _ensureInitialized(); + + try { + final jsonStr = await _channel.invokeMethod('query', { + 'clientId': _clientId, + 'keyword': keyword, + }); + + final response = json.decode(jsonStr); + + if (response['success'] == false) { + throw SearchException( + response['error'] ?? 'Query failed', + code: 'QUERY_FAILED', + ); + } + + // Parse results - server returns { success: true, data: { results: [...], pagination: {...} } } + final data = response['data']; + if (data == null) return []; + + // Check if data has results array (new format) + if (data is Map && data.containsKey('results')) { + final results = data['results']; + // Handle null results (no data found) + if (results == null) return []; + // Handle array results + if (results is List) { + return results.map((item) => SearchResult.fromJson(item as Map)).toList(); + } + // Handle single result as map + if (results is Map) { + return [SearchResult.fromJson(results)]; + } + return []; + } + + // Legacy: If data is a List, process it normally + if (data is List) { + return data.map((item) => SearchResult.fromJson(item as Map)).toList(); + } + + // Legacy: If data is a Map without results key (single result), wrap it in a list + if (data is Map && !data.containsKey('pagination')) { + return [SearchResult.fromJson(data)]; + } + + // Unknown format or empty + return []; + } on PlatformException catch (e) { + throw SearchException( + 'Query failed: ${e.message}', + code: e.code, + ); + } catch (e) { + throw SearchException( + 'Query failed: $e', + code: 'QUERY_ERROR', + ); + } + } + + /// Submit or update user data + /// Automatically performs Proof of Work (PoW) - may take several seconds + /// + /// Parameters: + /// - nknAddress: NKN client address (optional, format: "identifier.publickey" or just publickey) + /// If empty, defaults to publickey. Must be either "identifier.publickey" format or equal to publickey. + /// - customId: Custom identifier (optional, min 3 characters if provided, alphanumeric + underscore only) + /// - nickname: User nickname (optional) + /// - phoneNumber: Phone number (optional) + /// + /// Important: + /// - Each call performs fresh PoW (1-5 seconds on mobile) + /// - Rate limit: 10 submits per minute + /// - NO need to call verify() first + /// - If publicKey already exists, will UPDATE the user data + /// - nknAddress validation: must be empty, equal to publickey, or in "identifier.publickey" format + /// + /// Example: + /// ```dart + /// // Option 1: Use default (empty - will use publickey) + /// await client.submitUserData(); + /// + /// // Option 2: Use publickey directly + /// final pubKey = await client.getPublicKeyHex(); + /// await client.submitUserData(nknAddress: pubKey); + /// + /// // Option 3: Use custom identifier.publickey format + /// await client.submitUserData( + /// nknAddress: 'alice.$pubKey', // Custom identifier + publickey + /// customId: 'user123', // Optional, min 3 chars + /// nickname: 'Alice', // Optional + /// phoneNumber: '13800138000', // Optional + /// ); + /// ``` + Future submitUserData({ + String? nknAddress, + String? customId, + String? nickname, + String? phoneNumber, + }) async { + _ensureInitialized(); + _ensureAuthMode(); + + // Get publicKey for validation and default + final publicKeyHex = await getPublicKeyHex(); + + // Process nknAddress: if not provided or invalid, use publicKey + String finalNknAddress = nknAddress ?? publicKeyHex; + + // Validate nknAddress format if it contains a dot + if (finalNknAddress.contains('.')) { + final parts = finalNknAddress.split('.'); + if (parts.length != 2) { + throw SearchException( + 'Invalid nknAddress format. Expected: "identifier.publickey"', + code: 'INVALID_PARAMETER', + ); + } + final providedPubKey = parts[1]; + if (providedPubKey.toLowerCase() != publicKeyHex.toLowerCase()) { + throw SearchException( + 'nknAddress publickey suffix must match your actual publicKey', + code: 'INVALID_PARAMETER', + ); + } + } else { + // If no dot, must equal publicKey + if (finalNknAddress.toLowerCase() != publicKeyHex.toLowerCase()) { + throw SearchException( + 'nknAddress must be either "identifier.publickey" format or equal to publicKey', + code: 'INVALID_PARAMETER', + ); + } + } + + // Validate customId if provided + if (customId != null && customId.isNotEmpty && customId.length < 3) { + throw SearchException( + 'customId must be at least 3 characters if provided', + code: 'INVALID_PARAMETER', + ); + } + + try { + await _channel.invokeMethod('submitUserData', { + 'clientId': _clientId, + 'nknAddress': finalNknAddress, + 'customId': customId ?? '', + 'nickname': nickname ?? '', + 'phoneNumber': phoneNumber ?? '', + }); + } on PlatformException catch (e) { + if (e.code == 'SUBMIT_FAILED' && e.message?.contains('429') == true) { + throw SearchException( + 'Rate limit exceeded. Max 10 submits per minute. Please wait and retry.', + code: 'RATE_LIMIT_EXCEEDED', + ); + } + throw SearchException( + 'Submit failed: ${e.message}', + code: e.code, + ); + } + } + + /// Verify the client (optional) + /// Completes PoW challenge to get 2-hour query access + /// + /// Only useful if you need to do many query operations. + /// WriteProfile does NOT require verification. + Future verify() async { + _ensureInitialized(); + _ensureAuthMode(); + + try { + await _channel.invokeMethod('verify', { + 'clientId': _clientId, + }); + } on PlatformException catch (e) { + throw SearchException( + 'Verification failed: ${e.message}', + code: e.code, + ); + } + } + + /// Query data by ID + /// Requires verification first + Future queryByID(String id) async { + _ensureInitialized(); + _ensureAuthMode(); + + try { + final jsonStr = await _channel.invokeMethod('queryByID', { + 'clientId': _clientId, + 'id': id, + }); + + + final response = json.decode(jsonStr); + + if (response['success'] == false) { + throw SearchException( + response['error'] ?? 'Query failed', + code: 'QUERY_FAILED', + ); + } + + final data = response['data']; + if (data == null) return null; + + // Handle new format: { results: [...], pagination: {...} } + if (data is Map && data.containsKey('results')) { + final results = data['results']; + // Handle null or empty results + if (results == null) return null; + if (results is List && results.isEmpty) return null; + // Return first result if available + if (results is List && results.isNotEmpty) { + return SearchResult.fromJson(results[0] as Map); + } + return null; + } + + // Legacy: data is a Map (single result) + if (data is Map && !data.containsKey('pagination')) { + return SearchResult.fromJson(data); + } + + return null; + } on PlatformException catch (e) { + if (e.code == 'QUERY_BY_ID_FAILED' && + e.message?.contains('not verified') == true) { + throw SearchException( + 'Not verified. Please call verify() first.', + code: 'NOT_VERIFIED', + ); + } + throw SearchException( + 'Query by ID failed: ${e.message}', + code: e.code, + ); + } catch (e) { + throw SearchException( + 'Query by ID failed: $e', + code: 'QUERY_ERROR', + ); + } + } + + /// Get my own information by nknAddress + /// + /// Parameters: + /// - address: The NKN address to query (can be "identifier.publickey" format or just publickey) + /// + /// Returns the user's information if found, or null if not found. + Future getMyInfo(String address) async { + _ensureInitialized(); + + try { + final jsonStr = await _channel.invokeMethod('getMyInfo', { + 'clientId': _clientId, + 'address': address, + }); + + final response = json.decode(jsonStr); + + if (response['success'] == false) { + throw SearchException( + response['error'] ?? 'Query failed', + code: 'QUERY_FAILED', + ); + } + + final data = response['data']; + if (data == null) return null; + + // Handle new format: { results: [...], pagination: {...} } + if (data is Map && data.containsKey('results')) { + final results = data['results']; + // Handle null or empty results + if (results == null) return null; + if (results is List && results.isEmpty) return null; + // Return first result if available + if (results is List && results.isNotEmpty) { + final firstResult = results[0]; + if (firstResult is Map) { + return SearchResult.fromJson(firstResult); + } + } + return null; + } + + // Legacy: data is a Map (single result) + if (data is Map && !data.containsKey('pagination')) { + return SearchResult.fromJson(data); + } + + return null; + } on PlatformException catch (e) { + throw SearchException( + 'Get my info failed: ${e.message}', + code: e.code, + ); + } catch (e) { + throw SearchException( + 'Get my info failed: $e', + code: 'QUERY_ERROR', + ); + } + } + + /// Get the public key in hex format + Future getPublicKeyHex() async { + _ensureInitialized(); + _ensureAuthMode(); + + try { + final result = await _channel.invokeMethod('getPublicKeyHex', { + 'clientId': _clientId, + }); + return result as String; + } on PlatformException catch (e) { + throw SearchException( + 'Failed to get public key: ${e.message}', + code: e.code, + ); + } + } + + /// Get the wallet address + Future getAddress() async { + _ensureInitialized(); + _ensureAuthMode(); + + try { + final result = await _channel.invokeMethod('getAddress', { + 'clientId': _clientId, + }); + return result as String; + } on PlatformException catch (e) { + throw SearchException( + 'Failed to get address: ${e.message}', + code: e.code, + ); + } + } + + /// Check if the client is verified + Future isVerified() async { + _ensureInitialized(); + _ensureAuthMode(); + + try { + final result = await _channel.invokeMethod('isVerified', { + 'clientId': _clientId, + }); + return result as bool; + } on PlatformException catch (e) { + throw SearchException( + 'Failed to check verification: ${e.message}', + code: e.code, + ); + } + } + + /// Dispose the client and free resources + /// Should be called when done using the client + Future dispose() async { + if (_clientId == null) return; + + try { + await _channel.invokeMethod('disposeClient', { + 'clientId': _clientId, + }); + _clientId = null; + } on PlatformException catch (e) { + // Ignore disposal errors + print('Warning: Failed to dispose client: ${e.message}'); + } + } + + void _ensureInitialized() { + if (_clientId == null) { + throw SearchException( + 'Client not initialized', + code: 'NOT_INITIALIZED', + ); + } + } + + void _ensureAuthMode() { + if (!isAuthMode) { + throw SearchException( + 'This operation requires an authenticated client. Use createWithAuth() instead.', + code: 'AUTH_REQUIRED', + ); + } + } +} + +/// Search result item (User data) +class SearchResult { + final String? id; + final String? publicKey; + final String? nknAddress; + final String? customId; + final String? nickname; + final String? phoneNumber; + final DateTime? createdAt; + final DateTime? updatedAt; + + SearchResult({ + this.id, + this.publicKey, + this.nknAddress, + this.customId, + this.nickname, + this.phoneNumber, + this.createdAt, + this.updatedAt, + }); + + factory SearchResult.fromJson(Map json) { + return SearchResult( + id: json['_id']?.toString() ?? json['id']?.toString(), + publicKey: json['publicKey']?.toString(), + nknAddress: json['nknAddress']?.toString(), + customId: json['customId']?.toString(), + nickname: json['nickname']?.toString(), + phoneNumber: json['phoneNumber']?.toString(), + createdAt: json['createdAt'] != null + ? DateTime.tryParse(json['createdAt'].toString()) + : null, + updatedAt: json['updatedAt'] != null + ? DateTime.tryParse(json['updatedAt'].toString()) + : null, + ); + } + + Map toJson() { + return { + 'id': id, + 'publicKey': publicKey, + 'nknAddress': nknAddress, + 'customId': customId, + 'nickname': nickname, + 'phoneNumber': phoneNumber, + 'createdAt': createdAt?.toIso8601String(), + 'updatedAt': updatedAt?.toIso8601String(), + }; + } + + @override + String toString() { + return 'SearchResult(id: $id, customId: $customId, nickname: $nickname, nknAddress: $nknAddress)'; + } +} + +/// Search exception +class SearchException implements Exception { + final String message; + final String? code; + + SearchException(this.message, {this.code}); + + @override + String toString() { + if (code != null) { + return 'SearchException($code): $message'; + } + return 'SearchException: $message'; + } +} diff --git a/lib/providers/custom_id_provider.dart b/lib/providers/custom_id_provider.dart new file mode 100644 index 000000000..eadaf694e --- /dev/null +++ b/lib/providers/custom_id_provider.dart @@ -0,0 +1,95 @@ +import 'dart:typed_data'; + +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import '../common/search_service/search_service.dart'; + +/// Custom ID state model +class CustomIdState { + final String? customId; + final bool isLoading; + final String? error; + + const CustomIdState({ + this.customId, + this.isLoading = false, + this.error, + }); + + CustomIdState copyWith({ + String? customId, + bool? isLoading, + String? error, + }) { + return CustomIdState( + customId: customId ?? this.customId, + isLoading: isLoading ?? this.isLoading, + error: error ?? this.error, + ); + } +} + +/// Custom ID Notifier - Riverpod 3.0 API (without code generation) +class CustomIdNotifier extends Notifier { + @override + CustomIdState build() { + return const CustomIdState(); + } + + /// Load custom ID from server + /// Parameters: + /// - seed: User's seed for authentication + /// - nknAddress: NKN client address (can be "identifier.publickey" format or just publickey) + Future loadCustomId(Uint8List seed, String nknAddress) async { + state = state.copyWith(isLoading: true, error: null); + + try { + // Create authenticated search service + final service = await SearchService.createWithAuth(seed: seed); + + // Get my info using the nknAddress + final myInfo = await service.getMyInfo(nknAddress: nknAddress); + + + // Dispose the service + await service.dispose(); + + if (myInfo == null) { + // No data found - user hasn't submitted custom ID yet (not an error) + state = state.copyWith( + customId: null, + isLoading: false, + error: null, + ); + } else { + // Update state with custom ID (can be null if not set) + state = state.copyWith( + customId: myInfo.customId, + isLoading: false, + error: null, + ); + } + } catch (e) { + state = state.copyWith( + customId: null, + isLoading: false, + error: e.toString(), + ); + } + } + + /// Update custom ID (after user submits) + void setCustomId(String? customId) { + state = state.copyWith(customId: customId); + } + + /// Clear custom ID + void clear() { + state = const CustomIdState(); + } +} + +/// Custom ID Provider - Riverpod 3.0 API +final customIdProvider = NotifierProvider(() { + return CustomIdNotifier(); +}); + diff --git a/lib/routes/contact.dart b/lib/routes/contact.dart index 6ce57494b..0af836339 100644 --- a/lib/routes/contact.dart +++ b/lib/routes/contact.dart @@ -3,6 +3,7 @@ import 'package:nmobile/routes/routes.dart'; import 'package:nmobile/screens/contact/add.dart'; import 'package:nmobile/screens/contact/chat_profile.dart'; import 'package:nmobile/screens/contact/home.dart'; +import 'package:nmobile/screens/contact/more_profile.dart'; import 'package:nmobile/screens/contact/profile.dart'; Map _routes = { @@ -10,6 +11,7 @@ Map _routes = { ContactAddScreen.routeName: (BuildContext context) => ContactAddScreen(), ContactProfileScreen.routeName: (BuildContext context, {arguments}) => ContactProfileScreen(arguments: arguments), ContactChatProfileScreen.routeName: (BuildContext context, {arguments}) => ContactChatProfileScreen(arguments: arguments), + ContactMoreProfileScreen.routeName: (BuildContext context, {arguments}) => ContactMoreProfileScreen(arguments: arguments), }; init() { diff --git a/lib/screens/contact/chat_profile.dart b/lib/screens/contact/chat_profile.dart index 15a7414bf..a8c0f4247 100644 --- a/lib/screens/contact/chat_profile.dart +++ b/lib/screens/contact/chat_profile.dart @@ -1,4 +1,9 @@ +import 'dart:typed_data'; + import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:font_awesome_flutter/font_awesome_flutter.dart'; +import 'package:nkn_sdk_flutter/utils/hex.dart'; import 'package:nmobile/common/locator.dart'; import 'package:nmobile/common/settings.dart'; import 'package:nmobile/components/base/stateful.dart'; @@ -10,6 +15,14 @@ import 'package:nmobile/schema/contact.dart'; import 'package:nmobile/utils/util.dart'; import 'package:qr_flutter/qr_flutter.dart'; +import '../../common/search_service/search_service.dart'; +import '../../components/dialog/bottom.dart'; +import '../../components/dialog/loading.dart'; +import '../../components/tip/toast.dart'; +import '../../providers/custom_id_provider.dart'; +import '../../utils/asset.dart'; +import 'more_profile.dart'; + class ContactChatProfileScreen extends BaseStateFulWidget { static final String routeName = "/contact/chat_profile"; static final String argContactSchema = "contact_schema"; @@ -42,6 +55,135 @@ class ContactChatProfileScreenState extends BaseStateFulWidgetState application.theme.backgroundLightColor), + padding: MaterialStateProperty.resolveWith((states) => EdgeInsets.only(left: 16, right: 16, top: topPad, bottom: botPad)), + shape: MaterialStateProperty.resolveWith( + (states) => RoundedRectangleBorder( + borderRadius: BorderRadius.vertical( + top: topRadius ? Radius.circular(12) : Radius.zero, + bottom: botRadius ? Radius.circular(12) : Radius.zero, + ), + ), + ), + ); + } + + _modifyCustomId() async { + // Get necessary credentials first + String? walletAddress = await walletCommon.getDefaultAddress(); + if (walletAddress == null || walletAddress.isEmpty) { + Toast.show(Settings.locale((s) => s.d_chat_not_login, ctx: context)); + return; + } + + String? seedHex = await walletCommon.getSeed(walletAddress); + if (seedHex == null || seedHex.isEmpty) { + Toast.show(Settings.locale((s) => s.d_chat_not_login, ctx: context)); + return; + } + + Uint8List seed; + try { + seed = hexDecode(seedHex); + } catch (e) { + Toast.show(Settings.locale((s) => s.d_chat_not_login, ctx: context)); + return; + } + + String? nknAddress = clientCommon.address; + if (nknAddress == null || nknAddress.isEmpty) { + Toast.show(Settings.locale((s) => s.d_chat_not_login, ctx: context)); + return; + } + + // Show input dialog with async validation + String? customId = await BottomDialog.of(Settings.appContext).showInput( + title: Settings.locale((s) => s.edit_custom_id, ctx: context), + inputTip: Settings.locale((s) => s.custom_id, ctx: context), + inputHint: Settings.locale((s) => s.input_custom_id, ctx: context), + value: '', + actionText: Settings.locale((s) => s.save, ctx: context), + minLength: 3, + maxLength: 30, + canTapClose: true, + asyncValidator: (value) async { + // First, validate format: only letters, numbers, and underscores + final formatRegex = RegExp(r'^[a-zA-Z0-9_]+$'); + if (!formatRegex.hasMatch(value)) { + return Settings.locale((s) => s.tip_custom_id_format, ctx: context); + } + + // Then check if the customId is already taken by someone else + try { + var service = await SearchService.createWithAuth(seed: seed); + final existingUser = await service.queryByID(value); + + // If found and it's not the current user, the ID is taken + if (existingUser != null) { + // Check if it's the current user's own ID + final myPublicKey = await service.getPublicKeyHex(); + if (existingUser?.publicKey?.toLowerCase() != myPublicKey.toLowerCase()) { + // ID is taken by another user + await service.dispose(); + return Settings.locale((s) => s.tip_custom_id_taken, ctx: context); + } else { + // ID is already set for current user + await service.dispose(); + return Settings.locale((s) => s.tip_custom_id_already_set, ctx: context); + } + } + + await service.dispose(); + return null; // Validation passed + } catch (e) { + return Settings.locale((s) => s.tip_submit_failed, ctx: context) + ': $e'; + } + }, + ); + + if (customId == null || customId.isEmpty) return; + + // If we reach here, validation passed. Now submit the data. + Loading.show(text: Settings.locale((s) => s.submitting, ctx: context)); + + try { + // Create authenticated search service + var service = await SearchService.createWithAuth(seed: seed); + + // Submit user data with custom ID + await service.submitUserData( + nknAddress: nknAddress, + customId: customId, + ); + + // Dispose the service + await service.dispose(); + + // Dismiss loading + Loading.dismiss(); + + // Update provider with new custom ID + final container = ProviderScope.containerOf(context, listen: false); + container.read(customIdProvider.notifier).setCustomId(customId); + + // Show success message + Toast.show(Settings.locale((s) => s.success, ctx: context)); + + // Navigate back + if (Navigator.of(this.context).canPop()) { + Navigator.of(this.context).pop(); + } + } catch (e) { + // Dismiss loading + Loading.dismiss(); + + // Show error message + Toast.show(Settings.locale((s) => s.tip_submit_failed, ctx: context) + ': $e'); + } + } + @override Widget build(BuildContext context) { return Layout( @@ -52,85 +194,174 @@ class ContactChatProfileScreenState extends BaseStateFulWidgetState[ - TextButton( - style: ButtonStyle( - padding: MaterialStateProperty.resolveWith((states) => EdgeInsets.all(16)), - backgroundColor: MaterialStateProperty.resolveWith((states) => application.theme.backgroundLightColor), - shape: MaterialStateProperty.resolveWith( - (states) => RoundedRectangleBorder(borderRadius: BorderRadius.all(Radius.circular(12))), - ), - ), - onPressed: () { - Util.copyText(this._contact.address); - }, - child: Column( - children: [ - Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - crossAxisAlignment: CrossAxisAlignment.center, + child: Consumer( + builder: (context, ref, child) { + final customIdState = ref.watch(customIdProvider); + final hasCustomId = customIdState.customId != null && customIdState.customId!.isNotEmpty; + + return Column( + children: [ + // Custom ID + TextButton( + style: _buttonStyle(topRadius: true, botRadius: hasCustomId, topPad: 12, botPad: 12), + onPressed: () async { + _modifyCustomId(); + }, + child: Row( children: [ + FaIcon( + FontAwesomeIcons.fingerprint, + size: 24, + color: application.theme.primaryColor, + ), + SizedBox(width: 10), Label( - Settings.locale((s) => s.d_chat_address, ctx: context), + Settings.locale((s) => s.custom_id, ctx: context), type: LabelType.bodyRegular, color: application.theme.fontColor1, ), - Icon( - Icons.content_copy, + SizedBox(width: 20), + Spacer(), + // Only show loading when there's no cached customId + (customIdState.isLoading && customIdState.customId == null) + ? SizedBox( + width: 16, + height: 16, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : Label( + hasCustomId ? customIdState.customId! : Settings.locale((s) => s.go_to_set, ctx: context), + type: LabelType.bodyRegular, + color: application.theme.fontColor2, + overflow: TextOverflow.fade, + textAlign: TextAlign.right, + ), + Asset.iconSvg( + 'right', + width: 24, color: application.theme.fontColor2, - size: 18, - ) + ), ], ), - SizedBox(height: 10), - Label( - this._contact.address, - type: LabelType.bodyRegular, - color: application.theme.fontColor2, - softWrap: true, + ), + SizedBox(height: 24), + + // Show different UI based on whether custom ID is set + if (hasCustomId) ...[ + // If has custom ID, show "View More Info" button + TextButton( + style: _buttonStyle(topRadius: true, botRadius: true, topPad: 12, botPad: 12), + onPressed: () { + ContactMoreProfileScreen.go(context, this._contact); + }, + child: Row( + children: [ + FaIcon( + FontAwesomeIcons.addressCard, + size: 24, + color: application.theme.primaryColor, + ), + SizedBox(width: 10), + Label( + Settings.locale((s) => s.view_more_info, ctx: context), + type: LabelType.bodyRegular, + color: application.theme.fontColor1, + ), + Spacer(), + Asset.iconSvg( + 'right', + width: 24, + color: application.theme.fontColor2, + ), + ], + ), + ), + SizedBox(height: 24), + ] else ...[ + // If no custom ID, show original D-Chat Address card + TextButton( + style: ButtonStyle( + padding: MaterialStateProperty.resolveWith((states) => EdgeInsets.all(16)), + backgroundColor: MaterialStateProperty.resolveWith((states) => application.theme.backgroundLightColor), + shape: MaterialStateProperty.resolveWith( + (states) => RoundedRectangleBorder(borderRadius: BorderRadius.all(Radius.circular(12))), + ), + ), + onPressed: () { + Util.copyText(this._contact.address); + Toast.show(Settings.locale((s) => s.copied, ctx: context)); + }, + child: Column( + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + Label( + Settings.locale((s) => s.d_chat_address, ctx: context), + type: LabelType.bodyRegular, + color: application.theme.fontColor1, + ), + Icon( + Icons.content_copy, + color: application.theme.fontColor2, + size: 18, + ) + ], + ), + SizedBox(height: 10), + Label( + this._contact.address, + type: LabelType.bodyRegular, + color: application.theme.fontColor2, + softWrap: true, + ), + ], + ), ), + SizedBox(height: 24), ], - ), - ), - SizedBox(height: 30), - Container( - decoration: BoxDecoration( - color: Colors.white, - borderRadius: BorderRadius.circular(12), - ), - padding: EdgeInsets.only(left: 16, right: 16, top: 30, bottom: 30), - child: Column( - children: [ - ContactAvatar( - contact: this._contact, - radius: 24, + + // QR Code section + Container( + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(12), ), - SizedBox(height: 20), - this._contact.address.isNotEmpty - ? Center( - child: QrImageView( - data: this._contact.address, - backgroundColor: application.theme.backgroundLightColor, - foregroundColor: application.theme.primaryColor, - version: QrVersions.auto, - size: 240.0, - ), - ) - : SizedBox.shrink(), - SizedBox(height: 20), - Label( - Settings.locale((s) => s.scan_show_me_desc, ctx: context), - type: LabelType.bodyRegular, - color: application.theme.fontColor2, - overflow: TextOverflow.fade, - textAlign: TextAlign.left, - softWrap: true, + padding: EdgeInsets.only(left: 16, right: 16, top: 30, bottom: 30), + child: Column( + children: [ + ContactAvatar( + contact: this._contact, + radius: 24, + ), + SizedBox(height: 20), + this._contact.address.isNotEmpty + ? Center( + child: QrImageView( + data: this._contact.address, + backgroundColor: application.theme.backgroundLightColor, + foregroundColor: application.theme.primaryColor, + version: QrVersions.auto, + size: 240.0, + ), + ) + : SizedBox.shrink(), + SizedBox(height: 20), + Label( + Settings.locale((s) => s.scan_show_me_desc, ctx: context), + type: LabelType.bodyRegular, + color: application.theme.fontColor2, + overflow: TextOverflow.fade, + textAlign: TextAlign.left, + softWrap: true, + ), + ], ), - ], - ), - ) - ], + ), + ], + ); + }, ), ), ); diff --git a/lib/screens/contact/more_profile.dart b/lib/screens/contact/more_profile.dart new file mode 100644 index 000000000..a35b73c72 --- /dev/null +++ b/lib/screens/contact/more_profile.dart @@ -0,0 +1,103 @@ +import 'package:flutter/material.dart'; +import 'package:nmobile/common/locator.dart'; +import 'package:nmobile/common/settings.dart'; +import 'package:nmobile/components/base/stateful.dart'; +import 'package:nmobile/components/layout/header.dart'; +import 'package:nmobile/components/layout/layout.dart'; +import 'package:nmobile/components/text/label.dart'; +import 'package:nmobile/components/tip/toast.dart'; +import 'package:nmobile/schema/contact.dart'; +import 'package:nmobile/utils/util.dart'; + +class ContactMoreProfileScreen extends BaseStateFulWidget { + static final String routeName = "/contact/more_profile"; + static final String argContactSchema = "contact_schema"; + + static Future go(BuildContext? context, ContactSchema schema) { + if (context == null) return Future.value(null); + return Navigator.pushNamed(context, routeName, arguments: { + argContactSchema: schema, + }); + } + + final Map? arguments; + + ContactMoreProfileScreen({Key? key, this.arguments}) : super(key: key); + + @override + ContactMoreProfileScreenState createState() => new ContactMoreProfileScreenState(); +} + +class ContactMoreProfileScreenState extends BaseStateFulWidgetState { + late ContactSchema _contact; + + @override + void initState() { + super.initState(); + } + + @override + void onRefreshArguments() { + _contact = widget.arguments?[ContactMoreProfileScreen.argContactSchema]; + } + + @override + Widget build(BuildContext context) { + return Layout( + headerColor: application.theme.backgroundColor4, + header: Header( + title: Settings.locale((s) => s.profile, ctx: context), + backgroundColor: application.theme.backgroundColor4, + ), + body: SingleChildScrollView( + padding: const EdgeInsets.only(top: 30, bottom: 30, left: 20, right: 20), + child: Column( + children: [ + // D-Chat Address card + TextButton( + style: ButtonStyle( + padding: MaterialStateProperty.resolveWith((states) => EdgeInsets.all(16)), + backgroundColor: MaterialStateProperty.resolveWith((states) => application.theme.backgroundLightColor), + shape: MaterialStateProperty.resolveWith( + (states) => RoundedRectangleBorder(borderRadius: BorderRadius.all(Radius.circular(12))), + ), + ), + onPressed: () { + Util.copyText(this._contact.address); + Toast.show(Settings.locale((s) => s.copied, ctx: context)); + }, + child: Column( + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + Label( + Settings.locale((s) => s.d_chat_address, ctx: context), + type: LabelType.bodyRegular, + color: application.theme.fontColor1, + ), + Icon( + Icons.content_copy, + color: application.theme.fontColor2, + size: 18, + ) + ], + ), + SizedBox(height: 10), + Label( + this._contact.address, + type: LabelType.bodyRegular, + color: application.theme.fontColor2, + softWrap: true, + ), + ], + ), + ), + ], + ), + ), + ); + } +} + diff --git a/lib/screens/contact/profile.dart b/lib/screens/contact/profile.dart index f534170b3..ce6cc2ca5 100644 --- a/lib/screens/contact/profile.dart +++ b/lib/screens/contact/profile.dart @@ -1,5 +1,6 @@ import 'dart:async'; import 'dart:io'; +import 'dart:typed_data'; import 'package:flutter/cupertino.dart'; import 'package:flutter/material.dart'; @@ -7,6 +8,8 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_slidable/flutter_slidable.dart'; import 'package:font_awesome_flutter/font_awesome_flutter.dart'; import 'package:image_cropper/image_cropper.dart'; +import 'package:nkn_sdk_flutter/utils/hex.dart'; +import 'package:nkn_sdk_flutter/wallet.dart'; import 'package:nmobile/app.dart'; import 'package:nmobile/common/locator.dart'; import 'package:nmobile/common/settings.dart'; @@ -35,6 +38,7 @@ import 'package:nmobile/utils/path.dart'; import 'package:nmobile/utils/util.dart'; import '../../providers/connected_provider.dart'; +import '../../providers/custom_id_provider.dart'; class ContactProfileScreen extends BaseStateFulWidget { static const String routeName = '/contact/profile'; @@ -138,12 +142,55 @@ class _ContactProfileScreenState extends BaseStateFulWidgetState s.save, ctx: context), maxLength: 20, - canTapClose: false, + canTapClose: true, ); + if (newName == null) return; if (_contact?.type == ContactType.me) { contactCommon.setSelfFullName(_contact?.address, newName?.trim(), null, notify: true); // await } else { @@ -411,6 +462,17 @@ class _ContactProfileScreenState extends BaseStateFulWidgetState[ + // Show fingerprint icon for custom ID, chat-id icon for D-Chat address + hasCustomId + ? FaIcon( + FontAwesomeIcons.fingerprint, + size: 24, + color: application.theme.primaryColor, + ) + : Asset.image('chat/chat-id.png', color: application.theme.primaryColor, width: 24), + SizedBox(width: 10), + Label( + hasCustomId ? Settings.locale((s) => s.custom_id, ctx: context) : Settings.locale((s) => s.d_chat_address, ctx: context), + type: LabelType.bodyRegular, + color: application.theme.fontColor1, + ), + Spacer(), + // Only show loading when there's no cached customId + (customIdState.isLoading && customIdState.customId == null) + ? SizedBox( + width: 16, + height: 16, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : Label( + displayText, + type: LabelType.bodyRegular, + color: application.theme.fontColor2, + overflow: TextOverflow.fade, + textAlign: TextAlign.right, + ), + Asset.iconSvg( + 'right', + width: 24, + color: application.theme.fontColor2, + ), + ], + ), + ); + }, + ), + Padding( + padding: const EdgeInsets.only(top: 6, left: 20, right: 20), + child: Label( + Settings.locale((s) => s.custom_id_tips, ctx: context), + type: LabelType.bodySmall, + color: application.theme.fontColor2, + fontWeight: FontWeight.w600, + softWrap: true, + ), + ), + SizedBox(height: 24), + /// name TextButton( style: _buttonStyle(topRadius: true, botRadius: false, topPad: 15, botPad: 10), @@ -599,39 +728,39 @@ class _ContactProfileScreenState extends BaseStateFulWidgetState[ - Asset.image('chat/chat-id.png', color: application.theme.primaryColor, width: 24), - SizedBox(width: 10), - Label( - Settings.locale((s) => s.d_chat_address, ctx: context), - type: LabelType.bodyRegular, - color: application.theme.fontColor1, - ), - SizedBox(width: 20), - Expanded( - child: Label( - _getClientAddressShow(), - type: LabelType.bodyRegular, - color: application.theme.fontColor2, - overflow: TextOverflow.fade, - textAlign: TextAlign.right, - ), - ), - Asset.iconSvg( - 'right', - width: 24, - color: application.theme.fontColor2, - ), - ], - ), - ), + // TextButton( + // style: _buttonStyle(topRadius: false, botRadius: false, topPad: 12, botPad: 12), + // onPressed: () { + // if (this._contact == null) return; + // ContactChatProfileScreen.go(this.context, this._contact!); + // }, + // child: Row( + // children: [ + // Asset.image('chat/chat-id.png', color: application.theme.primaryColor, width: 24), + // SizedBox(width: 10), + // Label( + // Settings.locale((s) => s.d_chat_address, ctx: context), + // type: LabelType.bodyRegular, + // color: application.theme.fontColor1, + // ), + // Spacer(), + // Expanded( + // child: Label( + // _getClientAddressShow(), + // type: LabelType.bodyRegular, + // color: application.theme.fontColor2, + // overflow: TextOverflow.fade, + // textAlign: TextAlign.right, + // ), + // ), + // Asset.iconSvg( + // 'right', + // width: 24, + // color: application.theme.fontColor2, + // ), + // ], + // ), + // ), /// wallet TextButton( diff --git a/plugins/nkn-sdk-flutter/ios/nkn_sdk_flutter.podspec b/plugins/nkn-sdk-flutter/ios/nkn_sdk_flutter.podspec index 972468190..b69f6d7a9 100644 --- a/plugins/nkn-sdk-flutter/ios/nkn_sdk_flutter.podspec +++ b/plugins/nkn-sdk-flutter/ios/nkn_sdk_flutter.podspec @@ -16,7 +16,7 @@ nkn-sdk-flutter s.source_files = 'Classes/**/*' s.dependency 'Flutter' s.platform = :ios, '11.0' - + s.libraries = 'resolv' s.vendored_frameworks = 'Frameworks/*.xcframework' # Flutter.framework does not contain a i386 slice. From 210b662b5a83891c7c380fac71b56f8a3caed3a5 Mon Sep 17 00:00:00 2001 From: Heron Date: Sat, 1 Nov 2025 17:02:52 +0800 Subject: [PATCH 28/47] Add custom ID to chat --- ios/Runner.xcodeproj/project.pbxproj | 6 +- lib/common/contact/contact.dart | 36 +++++- lib/components/dialog/bottom.dart | 68 +++++++---- lib/generated/intl/messages_en.dart | 3 + lib/generated/intl/messages_zh_CN.dart | 3 + lib/generated/intl/messages_zh_TW.dart | 3 + lib/generated/l10n.dart | 20 ++++ lib/l10n/intl_en.arb | 2 + lib/l10n/intl_zh_CN.arb | 2 + lib/l10n/intl_zh_TW.arb | 2 + lib/screens/chat/home.dart | 22 +++- lib/screens/contact/chat_profile.dart | 153 ++++++++----------------- lib/screens/contact/profile.dart | 47 +------- pubspec.yaml | 2 +- 14 files changed, 184 insertions(+), 185 deletions(-) diff --git a/ios/Runner.xcodeproj/project.pbxproj b/ios/Runner.xcodeproj/project.pbxproj index f708566a1..f17e79ac8 100644 --- a/ios/Runner.xcodeproj/project.pbxproj +++ b/ios/Runner.xcodeproj/project.pbxproj @@ -875,7 +875,7 @@ CODE_SIGN_ENTITLEMENTS = Runner/Runner.entitlements; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 344; + CURRENT_PROJECT_VERSION = 345; DEVELOPMENT_TEAM = 67P82ZQDAS; ENABLE_BITCODE = NO; INFOPLIST_FILE = Runner/Info.plist; @@ -1011,7 +1011,7 @@ CODE_SIGN_ENTITLEMENTS = Runner/Runner.entitlements; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 344; + CURRENT_PROJECT_VERSION = 345; DEVELOPMENT_TEAM = 67P82ZQDAS; ENABLE_BITCODE = NO; INFOPLIST_FILE = Runner/Info.plist; @@ -1039,7 +1039,7 @@ CODE_SIGN_ENTITLEMENTS = Runner/Runner.entitlements; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 344; + CURRENT_PROJECT_VERSION = 345; DEVELOPMENT_TEAM = 67P82ZQDAS; ENABLE_BITCODE = NO; INFOPLIST_FILE = Runner/Info.plist; diff --git a/lib/common/contact/contact.dart b/lib/common/contact/contact.dart index 5468f71b4..166df9549 100644 --- a/lib/common/contact/contact.dart +++ b/lib/common/contact/contact.dart @@ -1,5 +1,7 @@ import 'dart:async'; +import 'dart:typed_data'; +import 'package:nkn_sdk_flutter/utils/hex.dart'; import 'package:nmobile/common/locator.dart'; import 'package:nmobile/common/name_service/resolver.dart'; import 'package:nmobile/helpers/error.dart'; @@ -11,10 +13,14 @@ import 'package:nmobile/utils/logger.dart'; import 'package:nmobile/utils/path.dart'; import 'package:uuid/uuid.dart'; +import '../search_service/search_service.dart'; + class ContactCommon with Tag { // ignore: close_sinks StreamController _addController = StreamController.broadcast(); + StreamSink get _addSink => _addController.sink; + Stream get addStream => _addController.stream; // ignore: close_sinks @@ -24,12 +30,16 @@ class ContactCommon with Tag { // ignore: close_sinks StreamController _updateController = StreamController.broadcast(); + StreamSink get _updateSink => _updateController.sink; + Stream get updateStream => _updateController.stream; // ignore: close_sinks StreamController _meUpdateController = StreamController.broadcast(); + StreamSink get meUpdateSink => _meUpdateController.sink; + Stream get meUpdateStream => _meUpdateController.stream; ContactCommon(); @@ -58,12 +68,26 @@ class ContactCommon with Tag { if ((address == null) || address.isEmpty) return null; // address String? clientAddress; - try { - Resolver resolver = Resolver(); - clientAddress = await resolver.resolve(address); - } catch (e, st) { - handleError(e, st); + // TODO + // try { + // Resolver resolver = Resolver(); + // clientAddress = await resolver.resolve(address); + // } catch (e, st) { + // handleError(e, st); + // } + + if (clientAddress == null) { + Uint8List? seed = clientCommon.getSeed(); + if (seed == null) { + String? walletAddress = await walletCommon.getDefaultAddress(); + seed = hexDecode(await walletCommon.getSeed(walletAddress)); + } + final service = await SearchService.createWithAuth(seed: seed); + + final response = await service.queryByID(address); + clientAddress = response?.nknAddress; } + bool resolveOk = false; if ((clientAddress != null) && Validate.isNknChatIdentifierOk(clientAddress)) { resolveOk = true; @@ -106,7 +130,7 @@ class ContactCommon with Tag { List contacts = await queryList(type: ContactType.me, orderDesc: false, limit: 1); String myAddress = selfAddress ?? clientCommon.address ?? ""; // TODO: fix multiple me - for(int i = 0; i < contacts.length; i++) { + for (int i = 0; i < contacts.length; i++) { if (myAddress.isNotEmpty && contacts[i].address != myAddress) { await setType(contacts[i].address, ContactType.none, notify: false); contacts.remove(contacts[i]); diff --git a/lib/components/dialog/bottom.dart b/lib/components/dialog/bottom.dart index e849fc653..0f1d96c9a 100644 --- a/lib/components/dialog/bottom.dart +++ b/lib/components/dialog/bottom.dart @@ -300,6 +300,7 @@ class BottomDialog extends BaseStateFulWidget { TextEditingController _inputController = TextEditingController(); _inputController.text = value ?? ""; ValueNotifier errorNotifier = ValueNotifier(null); + ValueNotifier loadingNotifier = ValueNotifier(false); return showWithTitle( title: title, @@ -308,26 +309,53 @@ class BottomDialog extends BaseStateFulWidget { canTapClose: canTapClose, action: Padding( padding: const EdgeInsets.only(left: 20, right: 20, top: 8, bottom: 34), - child: Button( - text: actionText ?? Settings.locale((s) => s.continue_text, ctx: context), - width: double.infinity, - onPressed: () async { - // Validate minimum length - if (minLength > 0 && _inputController.text.length < minLength) { - errorNotifier.value = Settings.locale((s) => s.tip_input_min_length(minLength.toString()), ctx: context); - return; - } - - // Async validation - if (asyncValidator != null) { - final error = await asyncValidator(_inputController.text); - if (error != null) { - errorNotifier.value = error; - return; - } - } - - if (Navigator.of(this.context).canPop()) Navigator.pop(this.context, _inputController.text); + child: ValueListenableBuilder( + valueListenable: loadingNotifier, + builder: (context, isLoading, child) { + return Button( + disabled: isLoading, + width: double.infinity, + child: isLoading + ? SizedBox( + width: 20, + height: 20, + child: CircularProgressIndicator( + strokeWidth: 2, + valueColor: AlwaysStoppedAnimation(application.theme.fontLightColor), + ), + ) + : Text( + actionText ?? Settings.locale((s) => s.continue_text, ctx: context), + style: TextStyle( + fontSize: application.theme.buttonFontSize, + color: application.theme.fontLightColor, + fontWeight: FontWeight.bold, + ), + ), + onPressed: () async { + // Validate minimum length + if (minLength > 0 && _inputController.text.length < minLength) { + errorNotifier.value = Settings.locale((s) => s.tip_input_min_length(minLength.toString()), ctx: context); + return; + } + + // Async validation + if (asyncValidator != null) { + loadingNotifier.value = true; + try { + final error = await asyncValidator(_inputController.text); + if (error != null) { + errorNotifier.value = error; + return; + } + } finally { + loadingNotifier.value = false; + } + } + + if (Navigator.of(this.context).canPop()) Navigator.pop(this.context, _inputController.text); + }, + ); }, ), ), diff --git a/lib/generated/intl/messages_en.dart b/lib/generated/intl/messages_en.dart index 05c701d30..8e0a247d8 100644 --- a/lib/generated/intl/messages_en.dart +++ b/lib/generated/intl/messages_en.dart @@ -314,6 +314,7 @@ class MessageLookup extends MessageLookupByLibrary { "hint_enter_wallet_name": MessageLookupByLibrary.simpleMessage("Enter wallet name"), "hours": MessageLookupByLibrary.simpleMessage("hours"), + "id": MessageLookupByLibrary.simpleMessage("ID"), "image": MessageLookupByLibrary.simpleMessage("Image"), "import_contacts": MessageLookupByLibrary.simpleMessage("Import Contacts"), @@ -574,6 +575,8 @@ class MessageLookup extends MessageLookupByLibrary { "tab_seed": MessageLookupByLibrary.simpleMessage("Seed"), "terms": MessageLookupByLibrary.simpleMessage("Terms"), "tip": MessageLookupByLibrary.simpleMessage("Tips"), + "tip_address_not_found": MessageLookupByLibrary.simpleMessage( + "Address not found or cannot be resolved"), "tip_ask_group_owner_permission": MessageLookupByLibrary.simpleMessage( "You are not in this group,ask the group owner for permission"), "tip_custom_id_already_set": MessageLookupByLibrary.simpleMessage( diff --git a/lib/generated/intl/messages_zh_CN.dart b/lib/generated/intl/messages_zh_CN.dart index 0c03b4562..80be84481 100644 --- a/lib/generated/intl/messages_zh_CN.dart +++ b/lib/generated/intl/messages_zh_CN.dart @@ -263,6 +263,7 @@ class MessageLookup extends MessageLookupByLibrary { "help": MessageLookupByLibrary.simpleMessage("帮助"), "hint_enter_wallet_name": MessageLookupByLibrary.simpleMessage("账户名称"), "hours": MessageLookupByLibrary.simpleMessage("小时"), + "id": MessageLookupByLibrary.simpleMessage("ID"), "image": MessageLookupByLibrary.simpleMessage("图片"), "import_contacts": MessageLookupByLibrary.simpleMessage("导入联系人"), "import_ethereum_wallet": @@ -481,6 +482,8 @@ class MessageLookup extends MessageLookupByLibrary { "tab_seed": MessageLookupByLibrary.simpleMessage("Seed"), "terms": MessageLookupByLibrary.simpleMessage("条款"), "tip": MessageLookupByLibrary.simpleMessage("提示"), + "tip_address_not_found": + MessageLookupByLibrary.simpleMessage("该地址不存在或无法解析"), "tip_ask_group_owner_permission": MessageLookupByLibrary.simpleMessage("您已不在此群,联系群主邀请您"), "tip_custom_id_already_set": diff --git a/lib/generated/intl/messages_zh_TW.dart b/lib/generated/intl/messages_zh_TW.dart index d71a6efad..0fbf95da0 100644 --- a/lib/generated/intl/messages_zh_TW.dart +++ b/lib/generated/intl/messages_zh_TW.dart @@ -263,6 +263,7 @@ class MessageLookup extends MessageLookupByLibrary { "help": MessageLookupByLibrary.simpleMessage("幫助"), "hint_enter_wallet_name": MessageLookupByLibrary.simpleMessage("賬戶名稱"), "hours": MessageLookupByLibrary.simpleMessage("小時"), + "id": MessageLookupByLibrary.simpleMessage("ID"), "image": MessageLookupByLibrary.simpleMessage("圖片"), "import_contacts": MessageLookupByLibrary.simpleMessage("導入聯繫人"), "import_ethereum_wallet": @@ -481,6 +482,8 @@ class MessageLookup extends MessageLookupByLibrary { "tab_seed": MessageLookupByLibrary.simpleMessage("Seed"), "terms": MessageLookupByLibrary.simpleMessage("條款"), "tip": MessageLookupByLibrary.simpleMessage("提示"), + "tip_address_not_found": + MessageLookupByLibrary.simpleMessage("該地址不存在或無法解析"), "tip_ask_group_owner_permission": MessageLookupByLibrary.simpleMessage("您已不在此群,聯繫群主邀請您"), "tip_custom_id_already_set": diff --git a/lib/generated/l10n.dart b/lib/generated/l10n.dart index 3de08fa33..2e112631d 100644 --- a/lib/generated/l10n.dart +++ b/lib/generated/l10n.dart @@ -4160,6 +4160,16 @@ class S { ); } + /// `ID` + String get id { + return Intl.message( + 'ID', + name: 'id', + desc: '', + args: [], + ); + } + /// `Custom ID` String get custom_id { return Intl.message( @@ -4240,6 +4250,16 @@ class S { ); } + /// `Address not found or cannot be resolved` + String get tip_address_not_found { + return Intl.message( + 'Address not found or cannot be resolved', + name: 'tip_address_not_found', + desc: '', + args: [], + ); + } + /// `View More Info` String get view_more_info { return Intl.message( diff --git a/lib/l10n/intl_en.arb b/lib/l10n/intl_en.arb index 39865f299..6a8a9058c 100644 --- a/lib/l10n/intl_en.arb +++ b/lib/l10n/intl_en.arb @@ -430,6 +430,7 @@ "revoke": "Revoke", "confirm_revoke": "Are you sure you want to revoke this message?", "edit_custom_id": "Edit Custom ID", + "id": "ID", "custom_id": "Custom ID", "custom_id_tips": "You can set a custom ID so that others can add you as a friend using this ID.", "tip_input_min_length": "Please enter at least {length} characters.", @@ -438,6 +439,7 @@ "tip_submit_failed": "Submit failed", "tip_custom_id_taken": "This custom ID is already taken by another user", "tip_custom_id_already_set": "This custom ID is already set as your current ID", + "tip_address_not_found": "Address not found or cannot be resolved", "view_more_info": "View More Info", "tip_custom_id_format": "Custom ID can only contain letters, numbers, or underscores" } \ No newline at end of file diff --git a/lib/l10n/intl_zh_CN.arb b/lib/l10n/intl_zh_CN.arb index 50989489a..5042f8b2f 100644 --- a/lib/l10n/intl_zh_CN.arb +++ b/lib/l10n/intl_zh_CN.arb @@ -430,6 +430,7 @@ "revoke": "撤回", "confirm_revoke": "确定要撤回此消息吗?", "edit_custom_id": "编辑自定义ID", + "id": "ID", "custom_id": "自定义ID", "custom_id_tips": "您可以设置自定义ID,以便于其他人通过此ID添加您为好友。", "tip_input_min_length": "请至少输入 {length} 个字符。", @@ -438,6 +439,7 @@ "tip_submit_failed": "提交失败", "tip_custom_id_taken": "此自定义ID已被其他用户占用", "tip_custom_id_already_set": "此自定义ID已经是您当前的ID,无需重复设置", + "tip_address_not_found": "该地址不存在或无法解析", "view_more_info": "查看更多信息", "tip_custom_id_format": "自定义ID只能包含字母、数字或下划线" } \ No newline at end of file diff --git a/lib/l10n/intl_zh_TW.arb b/lib/l10n/intl_zh_TW.arb index e710884b7..66dc254f6 100644 --- a/lib/l10n/intl_zh_TW.arb +++ b/lib/l10n/intl_zh_TW.arb @@ -430,6 +430,7 @@ "revoke": "撤回", "confirm_revoke": "確定要撤回此消息嗎?", "edit_custom_id": "編輯自定義ID", + "id": "ID", "custom_id": "自定義ID", "custom_id_tips": "您可以設定自訂ID,以便其他人透過此ID新增您為好友。", "tip_input_min_length": "請至少輸入 {length} 個字符。", @@ -438,6 +439,7 @@ "tip_submit_failed": "提交失敗", "tip_custom_id_taken": "此自訂ID已被其他用戶佔用", "tip_custom_id_already_set": "此自訂ID已經是您目前的ID,無需重複設定", + "tip_address_not_found": "該地址不存在或無法解析", "view_more_info": "查看更多資訊", "tip_custom_id_format": "自訂ID只能包含字母、數字或底線" } \ No newline at end of file diff --git a/lib/screens/chat/home.dart b/lib/screens/chat/home.dart index ea7e4269f..35efa1800 100644 --- a/lib/screens/chat/home.dart +++ b/lib/screens/chat/home.dart @@ -523,17 +523,31 @@ class _ChatHomeScreenState extends BaseStateFulWidgetState with backgroundColor: application.theme.backgroundLightColor.withAlpha(77), child: Asset.iconSvg('user', width: 24, color: application.theme.fontLightColor), onPressed: () async { + ContactSchema? validatedContact; + String? address = await BottomDialog.of(Settings.appContext).showInput( title: Settings.locale((s) => s.new_whisper, ctx: context), inputTip: Settings.locale((s) => s.send_to, ctx: context), inputHint: Settings.locale((s) => s.enter_or_select_a_user_pubkey, ctx: context), // validator: Validator.of(context).identifierNKN(), contactSelect: true, + asyncValidator: (value) async { + if (value.isEmpty) { + return null; // Allow empty to close dialog + } + + // Validate address exists and cache the result + validatedContact = await contactCommon.resolveByAddress(value, canAdd: true); + if (validatedContact == null) { + return Settings.locale((s) => s.tip_address_not_found, ctx: context); + } + return null; // Validation passed + }, ); - Loading.show(); - ContactSchema? contact = await contactCommon.resolveByAddress(address, canAdd: true); - Loading.dismiss(); - if (contact != null) await ChatMessagesScreen.go(context, contact); + + if (address != null && address.isNotEmpty && validatedContact != null) { + await ChatMessagesScreen.go(context, validatedContact!); + } if (Navigator.of(this.context).canPop()) Navigator.pop(this.context); // floatActionBtn }, ), diff --git a/lib/screens/contact/chat_profile.dart b/lib/screens/contact/chat_profile.dart index a8c0f4247..0ba7f9fde 100644 --- a/lib/screens/contact/chat_profile.dart +++ b/lib/screens/contact/chat_profile.dart @@ -2,7 +2,6 @@ import 'dart:typed_data'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; -import 'package:font_awesome_flutter/font_awesome_flutter.dart'; import 'package:nkn_sdk_flutter/utils/hex.dart'; import 'package:nmobile/common/locator.dart'; import 'package:nmobile/common/settings.dart'; @@ -134,7 +133,7 @@ class ContactChatProfileScreenState extends BaseStateFulWidgetState s.tip_custom_id_already_set, ctx: context); } } - + await service.dispose(); return null; // Validation passed } catch (e) { @@ -142,7 +141,7 @@ class ContactChatProfileScreenState extends BaseStateFulWidgetState s.success, ctx: context)); - // Navigate back - if (Navigator.of(this.context).canPop()) { - Navigator.of(this.context).pop(); - } } catch (e) { // Dismiss loading Loading.dismiss(); @@ -189,8 +184,16 @@ class ContactChatProfileScreenState extends BaseStateFulWidgetState s.d_chat_address, ctx: context), + title: Settings.locale((s) => s.profile, ctx: context), backgroundColor: application.theme.backgroundColor4, + actions: [ + IconButton( + icon: Asset.iconSvg('more', color: Colors.white, width: 24), + onPressed: () { + ContactMoreProfileScreen.go(context, this._contact); + }, + ) + ], ), body: SingleChildScrollView( padding: const EdgeInsets.only(top: 30, bottom: 30, left: 20, right: 20), @@ -199,128 +202,64 @@ class ContactChatProfileScreenState extends BaseStateFulWidgetState[ - // Custom ID + // ID TextButton( - style: _buttonStyle(topRadius: true, botRadius: hasCustomId, topPad: 12, botPad: 12), - onPressed: () async { + style: _buttonStyle(topRadius: true, botRadius: false, topPad: 20, botPad: 10), + onPressed: () { _modifyCustomId(); }, child: Row( children: [ - FaIcon( - FontAwesomeIcons.fingerprint, - size: 24, - color: application.theme.primaryColor, - ), + // Show fingerprint icon for custom ID, chat-id icon for D-Chat address + Asset.image('chat/chat-id.png', color: application.theme.primaryColor, width: 24), SizedBox(width: 10), Label( - Settings.locale((s) => s.custom_id, ctx: context), + Settings.locale((s) => s.id, ctx: context), type: LabelType.bodyRegular, color: application.theme.fontColor1, ), - SizedBox(width: 20), Spacer(), - // Only show loading when there's no cached customId - (customIdState.isLoading && customIdState.customId == null) - ? SizedBox( - width: 16, - height: 16, - child: CircularProgressIndicator(strokeWidth: 2), - ) - : Label( - hasCustomId ? customIdState.customId! : Settings.locale((s) => s.go_to_set, ctx: context), - type: LabelType.bodyRegular, - color: application.theme.fontColor2, - overflow: TextOverflow.fade, - textAlign: TextAlign.right, - ), - Asset.iconSvg( - 'right', - width: 24, + + Icon( + Icons.edit, color: application.theme.fontColor2, + size: 18, ), ], ), ), - SizedBox(height: 24), - - // Show different UI based on whether custom ID is set - if (hasCustomId) ...[ - // If has custom ID, show "View More Info" button - TextButton( - style: _buttonStyle(topRadius: true, botRadius: true, topPad: 12, botPad: 12), - onPressed: () { - ContactMoreProfileScreen.go(context, this._contact); - }, - child: Row( - children: [ - FaIcon( - FontAwesomeIcons.addressCard, - size: 24, - color: application.theme.primaryColor, - ), - SizedBox(width: 10), - Label( - Settings.locale((s) => s.view_more_info, ctx: context), - type: LabelType.bodyRegular, - color: application.theme.fontColor1, - ), - Spacer(), - Asset.iconSvg( - 'right', - width: 24, - color: application.theme.fontColor2, - ), - ], - ), - ), - SizedBox(height: 24), - ] else ...[ - // If no custom ID, show original D-Chat Address card - TextButton( - style: ButtonStyle( - padding: MaterialStateProperty.resolveWith((states) => EdgeInsets.all(16)), - backgroundColor: MaterialStateProperty.resolveWith((states) => application.theme.backgroundLightColor), - shape: MaterialStateProperty.resolveWith( - (states) => RoundedRectangleBorder(borderRadius: BorderRadius.all(Radius.circular(12))), - ), - ), - onPressed: () { - Util.copyText(this._contact.address); - Toast.show(Settings.locale((s) => s.copied, ctx: context)); - }, - child: Column( - children: [ - Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - crossAxisAlignment: CrossAxisAlignment.center, - children: [ - Label( - Settings.locale((s) => s.d_chat_address, ctx: context), - type: LabelType.bodyRegular, - color: application.theme.fontColor1, - ), - Icon( - Icons.content_copy, - color: application.theme.fontColor2, - size: 18, - ) - ], - ), - SizedBox(height: 10), - Label( - this._contact.address, + TextButton( + style: _buttonStyle(topRadius: false, botRadius: true, topPad: 10, botPad: 20), + onPressed: () { + Util.copyText(displayId); + Toast.show(Settings.locale((s) => s.copied, ctx: context)); + }, + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Expanded( + child: Label( + displayId, type: LabelType.bodyRegular, color: application.theme.fontColor2, softWrap: true, ), - ], - ), + ), + SizedBox(width: 10), + Icon( + Icons.content_copy, + color: application.theme.fontColor2, + size: 18, + ), + ], ), - SizedBox(height: 24), - ], + ), + SizedBox(height: 24), // QR Code section Container( diff --git a/lib/screens/contact/profile.dart b/lib/screens/contact/profile.dart index ce6cc2ca5..dfd8004cb 100644 --- a/lib/screens/contact/profile.dart +++ b/lib/screens/contact/profile.dart @@ -643,16 +643,10 @@ class _ContactProfileScreenState extends BaseStateFulWidgetState[ // Show fingerprint icon for custom ID, chat-id icon for D-Chat address - hasCustomId - ? FaIcon( - FontAwesomeIcons.fingerprint, - size: 24, - color: application.theme.primaryColor, - ) - : Asset.image('chat/chat-id.png', color: application.theme.primaryColor, width: 24), + Asset.image('chat/chat-id.png', color: application.theme.primaryColor, width: 24), SizedBox(width: 10), Label( - hasCustomId ? Settings.locale((s) => s.custom_id, ctx: context) : Settings.locale((s) => s.d_chat_address, ctx: context), + Settings.locale((s) => s.id, ctx: context), type: LabelType.bodyRegular, color: application.theme.fontColor1, ), @@ -727,41 +721,6 @@ class _ContactProfileScreenState extends BaseStateFulWidgetState[ - // Asset.image('chat/chat-id.png', color: application.theme.primaryColor, width: 24), - // SizedBox(width: 10), - // Label( - // Settings.locale((s) => s.d_chat_address, ctx: context), - // type: LabelType.bodyRegular, - // color: application.theme.fontColor1, - // ), - // Spacer(), - // Expanded( - // child: Label( - // _getClientAddressShow(), - // type: LabelType.bodyRegular, - // color: application.theme.fontColor2, - // overflow: TextOverflow.fade, - // textAlign: TextAlign.right, - // ), - // ), - // Asset.iconSvg( - // 'right', - // width: 24, - // color: application.theme.fontColor2, - // ), - // ], - // ), - // ), - /// wallet TextButton( style: _buttonStyle(topRadius: false, botRadius: true, topPad: 10, botPad: 15), @@ -970,7 +929,7 @@ class _ContactProfileScreenState extends BaseStateFulWidgetState s.d_chat_address, ctx: context), + Settings.locale((s) => s.id, ctx: context), type: LabelType.bodyRegular, color: application.theme.fontColor1, ), diff --git a/pubspec.yaml b/pubspec.yaml index bba0e468e..09a17ecc4 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -16,7 +16,7 @@ publish_to: 'none' # Remove this line if you wish to publish to pub.dev # Read more about iOS versioning at # https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html -version: 1.7.2+344 +version: 1.7.2+345 environment: sdk: ">=2.12.0 <3.0.0" From 7e84828a7eb127888f7217f0cb9403718251d988 Mon Sep 17 00:00:00 2001 From: Heron Date: Mon, 3 Nov 2025 16:44:58 +0800 Subject: [PATCH 29/47] Clear unread message when delete --- ios/Runner.xcodeproj/project.pbxproj | 16 ++++++++++++---- lib/screens/chat/session_list.dart | 9 +++++++++ pubspec.yaml | 2 +- 3 files changed, 22 insertions(+), 5 deletions(-) diff --git a/ios/Runner.xcodeproj/project.pbxproj b/ios/Runner.xcodeproj/project.pbxproj index f17e79ac8..38ab422d2 100644 --- a/ios/Runner.xcodeproj/project.pbxproj +++ b/ios/Runner.xcodeproj/project.pbxproj @@ -3,7 +3,7 @@ archiveVersion = 1; classes = { }; - objectVersion = 54; + objectVersion = 77; objects = { /* Begin PBXBuildFile section */ @@ -545,10 +545,14 @@ inputFileListPaths = ( "${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-resources-${CONFIGURATION}-input-files.xcfilelist", ); + inputPaths = ( + ); name = "[CP] Copy Pods Resources"; outputFileListPaths = ( "${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-resources-${CONFIGURATION}-output-files.xcfilelist", ); + outputPaths = ( + ); runOnlyForDeploymentPostprocessing = 0; shellPath = /bin/sh; shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-resources.sh\"\n"; @@ -577,10 +581,14 @@ inputFileListPaths = ( "${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks-${CONFIGURATION}-input-files.xcfilelist", ); + inputPaths = ( + ); name = "[CP] Embed Pods Frameworks"; outputFileListPaths = ( "${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks-${CONFIGURATION}-output-files.xcfilelist", ); + outputPaths = ( + ); runOnlyForDeploymentPostprocessing = 0; shellPath = /bin/sh; shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks.sh\"\n"; @@ -875,7 +883,7 @@ CODE_SIGN_ENTITLEMENTS = Runner/Runner.entitlements; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 345; + CURRENT_PROJECT_VERSION = 346; DEVELOPMENT_TEAM = 67P82ZQDAS; ENABLE_BITCODE = NO; INFOPLIST_FILE = Runner/Info.plist; @@ -1011,7 +1019,7 @@ CODE_SIGN_ENTITLEMENTS = Runner/Runner.entitlements; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 345; + CURRENT_PROJECT_VERSION = 346; DEVELOPMENT_TEAM = 67P82ZQDAS; ENABLE_BITCODE = NO; INFOPLIST_FILE = Runner/Info.plist; @@ -1039,7 +1047,7 @@ CODE_SIGN_ENTITLEMENTS = Runner/Runner.entitlements; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 345; + CURRENT_PROJECT_VERSION = 346; DEVELOPMENT_TEAM = 67P82ZQDAS; ENABLE_BITCODE = NO; INFOPLIST_FILE = Runner/Info.plist; diff --git a/lib/screens/chat/session_list.dart b/lib/screens/chat/session_list.dart index f0c946960..22e83d426 100644 --- a/lib/screens/chat/session_list.dart +++ b/lib/screens/chat/session_list.dart @@ -218,6 +218,12 @@ class _ChatSessionListLayoutState extends BaseStateFulWidgetState( context: context, @@ -269,7 +275,10 @@ class _ChatSessionListLayoutState extends BaseStateFulWidgetState=2.12.0 <3.0.0" From 92d8da7270b70a85b14d1388be74c1c4794cf419 Mon Sep 17 00:00:00 2001 From: Heron Date: Thu, 6 Nov 2025 16:25:00 +0800 Subject: [PATCH 30/47] Handling messages for which no receipt has been received --- ios/Runner.xcodeproj/project.pbxproj | 16 +- lib/components/chat/bubble.dart | 63 +----- lib/components/chat/message_item.dart | 3 + lib/screens/chat/home.dart | 198 +---------------- lib/screens/chat/messages.dart | 31 ++- lib/screens/contact/home.dart | 297 +++++++++++++++++++++----- pubspec.yaml | 2 +- 7 files changed, 288 insertions(+), 322 deletions(-) diff --git a/ios/Runner.xcodeproj/project.pbxproj b/ios/Runner.xcodeproj/project.pbxproj index 38ab422d2..afed0566d 100644 --- a/ios/Runner.xcodeproj/project.pbxproj +++ b/ios/Runner.xcodeproj/project.pbxproj @@ -3,7 +3,7 @@ archiveVersion = 1; classes = { }; - objectVersion = 77; + objectVersion = 54; objects = { /* Begin PBXBuildFile section */ @@ -545,14 +545,10 @@ inputFileListPaths = ( "${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-resources-${CONFIGURATION}-input-files.xcfilelist", ); - inputPaths = ( - ); name = "[CP] Copy Pods Resources"; outputFileListPaths = ( "${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-resources-${CONFIGURATION}-output-files.xcfilelist", ); - outputPaths = ( - ); runOnlyForDeploymentPostprocessing = 0; shellPath = /bin/sh; shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-resources.sh\"\n"; @@ -581,14 +577,10 @@ inputFileListPaths = ( "${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks-${CONFIGURATION}-input-files.xcfilelist", ); - inputPaths = ( - ); name = "[CP] Embed Pods Frameworks"; outputFileListPaths = ( "${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks-${CONFIGURATION}-output-files.xcfilelist", ); - outputPaths = ( - ); runOnlyForDeploymentPostprocessing = 0; shellPath = /bin/sh; shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks.sh\"\n"; @@ -883,7 +875,7 @@ CODE_SIGN_ENTITLEMENTS = Runner/Runner.entitlements; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 346; + CURRENT_PROJECT_VERSION = 347; DEVELOPMENT_TEAM = 67P82ZQDAS; ENABLE_BITCODE = NO; INFOPLIST_FILE = Runner/Info.plist; @@ -1019,7 +1011,7 @@ CODE_SIGN_ENTITLEMENTS = Runner/Runner.entitlements; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 346; + CURRENT_PROJECT_VERSION = 347; DEVELOPMENT_TEAM = 67P82ZQDAS; ENABLE_BITCODE = NO; INFOPLIST_FILE = Runner/Info.plist; @@ -1047,7 +1039,7 @@ CODE_SIGN_ENTITLEMENTS = Runner/Runner.entitlements; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 346; + CURRENT_PROJECT_VERSION = 347; DEVELOPMENT_TEAM = 67P82ZQDAS; ENABLE_BITCODE = NO; INFOPLIST_FILE = Runner/Info.plist; diff --git a/lib/components/chat/bubble.dart b/lib/components/chat/bubble.dart index 3c13a1b71..7fd40e542 100644 --- a/lib/components/chat/bubble.dart +++ b/lib/components/chat/bubble.dart @@ -29,6 +29,7 @@ class ChatBubble extends BaseStateFulWidget { final bool showTimeAndStatus; final bool hideTopMargin; final bool hideBotMargin; + final bool lastMessageHasReceipt; final Function(String)? onResend; ChatBubble({ @@ -36,6 +37,7 @@ class ChatBubble extends BaseStateFulWidget { this.showTimeAndStatus = true, this.hideTopMargin = false, this.hideBotMargin = false, + this.lastMessageHasReceipt = false, this.onResend, }); @@ -199,39 +201,15 @@ class _ChatBubbleState extends BaseStateFulWidgetState with Tag { } Widget _widgetStatusTip(bool self) { - // bool isSending = _message.status == MessageStatus.Sending; bool isSendFail = _message.status == MessageStatus.Error; - // bool isSendSuccess = _message.status == MessageStatus.SendSuccess; - // bool isSendReceipt = _message.status == MessageStatus.SendReceipt; - - // bool canProgress = _message.isContentFile && !_message.isTopic; - - // bool showSending = isSending && !canProgress; - // bool showProgress = isSending && canProgress && _uploadProgress < 1; - - // if (showSending) { - // return Padding( - // padding: const EdgeInsets.symmetric(horizontal: 10), - // child: SpinKitRing( - // color: application.theme.fontColor4, - // lineWidth: 1, - // size: 15, - // ), - // ); - // } else if (showProgress) { - // return Container( - // width: 40, - // height: 40, - // padding: EdgeInsets.all(10), - // child: CircularProgressIndicator( - // backgroundColor: application.theme.fontColor4.withAlpha(80), - // color: application.theme.primaryColor.withAlpha(200), - // strokeWidth: 2, - // value: _uploadProgress, - // ), - // ); - // } else - if (isSendFail) { + bool shouldShowResend = isSendFail; + if (!shouldShowResend && widget.lastMessageHasReceipt && _message.isOutbound) { + if (_message.status >= MessageStatus.Success && _message.status < MessageStatus.Receipt) { + shouldShowResend = true; + } + } + + if (shouldShowResend) { return ButtonIcon( icon: Icon( FontAwesomeIcons.circleExclamation, @@ -258,27 +236,6 @@ class _ChatBubbleState extends BaseStateFulWidgetState with Tag { }, ); } - // else if (isSendSuccess) { - // return Container( - // width: 5, - // height: 5, - // margin: EdgeInsets.only(left: 10, right: 10, bottom: 5, top: 5), - // decoration: BoxDecoration( - // borderRadius: BorderRadius.circular(5), - // color: application.theme.strongColor.withAlpha(127), - // ), - // ); - // } else if (isSendReceipt) { - // return Container( - // width: 5, - // height: 5, - // margin: EdgeInsets.only(left: 10, right: 10, bottom: 5, top: 5), - // decoration: BoxDecoration( - // borderRadius: BorderRadius.circular(5), - // color: application.theme.successColor.withAlpha(127), - // ), - // ); - // } return SizedBox.shrink(); } diff --git a/lib/components/chat/message_item.dart b/lib/components/chat/message_item.dart index d2759e0f9..96bc6be5b 100644 --- a/lib/components/chat/message_item.dart +++ b/lib/components/chat/message_item.dart @@ -23,6 +23,7 @@ class ChatMessageItem extends BaseStateFulWidget { final MessageSchema message; final MessageSchema? prevMessage; final MessageSchema? nextMessage; + final bool lastMessageHasReceipt; final Function(ContactSchema, MessageSchema)? onAvatarPress; final Function(ContactSchema, MessageSchema)? onAvatarLonePress; final Function(String)? onResend; @@ -31,6 +32,7 @@ class ChatMessageItem extends BaseStateFulWidget { required this.message, this.prevMessage, this.nextMessage, + this.lastMessageHasReceipt = false, this.onAvatarPress, this.onAvatarLonePress, this.onResend, @@ -246,6 +248,7 @@ class _ChatMessageItemState extends BaseStateFulWidgetState { showTimeAndStatus: showTimeAndStatus, hideTopMargin: hideTopMargin, hideBotMargin: hideBotMargin, + lastMessageHasReceipt: this.widget.lastMessageHasReceipt, onResend: this.widget.onResend, ), SizedBox(height: hideBotMargin ? 0 : 4), diff --git a/lib/screens/chat/home.dart b/lib/screens/chat/home.dart index 35efa1800..bcc3e4dc0 100644 --- a/lib/screens/chat/home.dart +++ b/lib/screens/chat/home.dart @@ -2,6 +2,7 @@ import 'dart:async'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_spinkit/flutter_spinkit.dart'; import 'package:nmobile/blocs/wallet/wallet_bloc.dart'; import 'package:nmobile/blocs/wallet/wallet_state.dart'; @@ -9,21 +10,14 @@ import 'package:nmobile/common/client/client.dart'; import 'package:nmobile/common/locator.dart'; import 'package:nmobile/common/settings.dart'; import 'package:nmobile/components/base/stateful.dart'; -import 'package:nmobile/components/button/button.dart'; import 'package:nmobile/components/contact/header.dart'; -import 'package:nmobile/components/dialog/bottom.dart'; -import 'package:nmobile/components/dialog/create_private_group.dart'; -import 'package:nmobile/components/dialog/loading.dart'; -import 'package:nmobile/components/layout/chat_topic_search.dart'; import 'package:nmobile/components/layout/header.dart'; import 'package:nmobile/components/layout/layout.dart'; import 'package:nmobile/components/text/label.dart'; -import 'package:nmobile/routes/routes.dart'; -import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:nmobile/providers/connected_provider.dart'; +import 'package:nmobile/routes/routes.dart'; import 'package:nmobile/schema/contact.dart'; import 'package:nmobile/schema/wallet.dart'; -import 'package:nmobile/screens/chat/messages.dart'; import 'package:nmobile/screens/chat/no_connect.dart'; import 'package:nmobile/screens/chat/no_wallet.dart'; import 'package:nmobile/screens/chat/session_list.dart'; @@ -40,8 +34,6 @@ class ChatHomeScreen extends BaseStateFulWidget { } class _ChatHomeScreenState extends BaseStateFulWidgetState with AutomaticKeepAliveClientMixin, RouteAware, Tag { - GlobalKey _floatingActionKey = GlobalKey(); - StreamSubscription? _upgradeTipListen; StreamSubscription? _dbOpenedSubscription; @@ -57,7 +49,6 @@ class _ChatHomeScreenState extends BaseStateFulWidgetState with int clientConnectStatus = ClientConnectStatus.connecting; bool isLoginProgress = false; - // connected state managed by Riverpod connectedProvider @override void onRefreshArguments() {} @@ -248,20 +239,6 @@ class _ChatHomeScreenState extends BaseStateFulWidgetState with ) ], ), - floatingActionButtonLocation: FloatingActionButtonLocation.endFloat, - floatingActionButton: Padding( - padding: EdgeInsets.only(bottom: 60, right: 4), - child: FloatingActionButton( - key: _floatingActionKey, - shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(32)), - elevation: 12, - backgroundColor: application.theme.primaryColor, - child: Asset.iconSvg('pencil', width: 24), - onPressed: () { - _showFloatActionMenu(); - }, - ), - ), body: (_contactMe != null) && dbOpen ? ChatSessionListLayout(_contactMe!) : Container( @@ -391,175 +368,4 @@ class _ChatHomeScreenState extends BaseStateFulWidgetState with ), ); } - - _showFloatActionMenu() { - double btnSize = 48; - - showDialog( - context: context, - builder: (context) { - return GestureDetector( - onTap: () { - if (Navigator.of(this.context).canPop()) Navigator.pop(this.context); - }, - child: Align( - alignment: Alignment.bottomRight, - child: Container( - padding: EdgeInsets.only(bottom: 67, right: 16), - child: Row( - children: [ - Spacer(), - Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.end, - children: [ - SizedBox( - height: btnSize, - child: Align( - alignment: Alignment.centerRight, - child: Container( - padding: EdgeInsets.symmetric(vertical: 4, horizontal: 8), - decoration: BoxDecoration( - borderRadius: BorderRadius.all(Radius.circular(12)), - color: Colors.black26, - ), - child: Label( - Settings.locale((s) => s.new_private_group, ctx: context), - height: 1.2, - type: LabelType.h4, - dark: true, - ), - ), - ), - ), - SizedBox(height: 10), - SizedBox( - height: btnSize, - child: Align( - alignment: Alignment.centerRight, - child: Container( - padding: EdgeInsets.symmetric(vertical: 4, horizontal: 8), - decoration: BoxDecoration( - borderRadius: BorderRadius.all(Radius.circular(12)), - color: Colors.black26, - ), - child: Label( - Settings.locale((s) => s.new_public_group, ctx: context), - height: 1.2, - type: LabelType.h4, - dark: true, - ), - ), - ), - ), - SizedBox(height: 10), - SizedBox( - height: btnSize, - child: Align( - alignment: Alignment.centerRight, - child: Container( - padding: EdgeInsets.symmetric(vertical: 4, horizontal: 8), - decoration: BoxDecoration( - borderRadius: BorderRadius.all(Radius.circular(12)), - color: Colors.black26, - ), - child: Label( - Settings.locale((s) => s.new_whisper, ctx: context), - height: 1.2, - type: LabelType.h4, - dark: true, - ), - ), - ), - ), - ], - ), - SizedBox(width: 8), - Container( - padding: EdgeInsets.symmetric(horizontal: 8, vertical: 12), - decoration: BoxDecoration( - borderRadius: BorderRadius.all(Radius.circular(32)), - color: application.theme.primaryColor, - ), - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - Button( - width: btnSize, - height: btnSize, - fontColor: application.theme.fontLightColor, - backgroundColor: application.theme.backgroundLightColor.withAlpha(77), - child: Asset.iconSvg('lock', width: 22, color: application.theme.fontLightColor), - onPressed: () async { - if (Navigator.of(this.context).canPop()) Navigator.pop(this.context); - BottomDialog.of(Settings.appContext).showWithTitle( - height: 300, - title: Settings.locale((s) => s.create_private_group, ctx: context), - child: CreatePrivateGroup(), - ); - }, - ), - SizedBox(height: 10), - Button( - width: btnSize, - height: btnSize, - fontColor: application.theme.fontLightColor, - backgroundColor: application.theme.backgroundLightColor.withAlpha(77), - child: Asset.iconSvg('group', width: 22, color: application.theme.fontLightColor), - onPressed: () async { - if (Navigator.of(this.context).canPop()) Navigator.pop(this.context); - BottomDialog.of(Settings.appContext).showWithTitle( - height: Settings.screenHeight() * 0.8, - title: Settings.locale((s) => s.create_channel, ctx: context), - child: ChatTopicSearchLayout(), - ); - }, - ), - SizedBox(height: 10), - Button( - width: btnSize, - height: btnSize, - fontColor: application.theme.fontLightColor, - backgroundColor: application.theme.backgroundLightColor.withAlpha(77), - child: Asset.iconSvg('user', width: 24, color: application.theme.fontLightColor), - onPressed: () async { - ContactSchema? validatedContact; - - String? address = await BottomDialog.of(Settings.appContext).showInput( - title: Settings.locale((s) => s.new_whisper, ctx: context), - inputTip: Settings.locale((s) => s.send_to, ctx: context), - inputHint: Settings.locale((s) => s.enter_or_select_a_user_pubkey, ctx: context), - // validator: Validator.of(context).identifierNKN(), - contactSelect: true, - asyncValidator: (value) async { - if (value.isEmpty) { - return null; // Allow empty to close dialog - } - - // Validate address exists and cache the result - validatedContact = await contactCommon.resolveByAddress(value, canAdd: true); - if (validatedContact == null) { - return Settings.locale((s) => s.tip_address_not_found, ctx: context); - } - return null; // Validation passed - }, - ); - - if (address != null && address.isNotEmpty && validatedContact != null) { - await ChatMessagesScreen.go(context, validatedContact!); - } - if (Navigator.of(this.context).canPop()) Navigator.pop(this.context); // floatActionBtn - }, - ), - ], - ), - ), - ], - ), - ), - ), - ); - }, - ); - } } diff --git a/lib/screens/chat/messages.dart b/lib/screens/chat/messages.dart index 1c92ab9a7..2c305a272 100644 --- a/lib/screens/chat/messages.dart +++ b/lib/screens/chat/messages.dart @@ -776,13 +776,38 @@ class _ChatMessagesScreenState extends BaseStateFulWidgetState= _messages.length) return SizedBox.shrink(); MessageSchema msg = _messages[index]; + bool shouldShowResend = false; + if (msg.isOutbound && + msg.status >= MessageStatus.Success && + msg.status < MessageStatus.Receipt) { + if (msg.status == MessageStatus.Error) { + shouldShowResend = true; + } else { + int now = DateTime.now().millisecondsSinceEpoch; + int sendAt = msg.sendAt; + int timeDiff = now - sendAt; + bool isWithin10Seconds = timeDiff < 10000; // 10秒 = 10000毫秒 + + if (!isWithin10Seconds) { + if (index > 0) { + for (int i = 0; i < index; i++) { + MessageSchema checkMsg = _messages[i]; + if (checkMsg.isOutbound && + checkMsg.status >= MessageStatus.Receipt) { + shouldShowResend = true; + break; + } + } + } + } + } + } + bool lastMessageHasReceipt = shouldShowResend; return ChatMessageItem( message: msg, - // sender: _sender, - // topic: _topic, - // privateGroup: _privateGroup, prevMessage: (index - 1) >= 0 ? _messages[index - 1] : null, nextMessage: (index + 1) < _messages.length ? _messages[index + 1] : null, + lastMessageHasReceipt: lastMessageHasReceipt, onAvatarPress: (ContactSchema contact, _) { ContactProfileScreen.go(context, schema: contact); }, diff --git a/lib/screens/contact/home.dart b/lib/screens/contact/home.dart index 7da0a4ac7..df8bc89e7 100644 --- a/lib/screens/contact/home.dart +++ b/lib/screens/contact/home.dart @@ -18,6 +18,9 @@ import 'package:nmobile/components/text/fixed_text_field.dart'; import 'package:nmobile/components/text/label.dart'; import 'package:nmobile/components/tip/toast.dart'; import 'package:nmobile/components/topic/item.dart'; +import 'package:nmobile/components/dialog/bottom.dart'; +import 'package:nmobile/components/dialog/create_private_group.dart'; +import 'package:nmobile/components/layout/chat_topic_search.dart'; import 'package:nmobile/schema/contact.dart'; import 'package:nmobile/schema/private_group.dart'; import 'package:nmobile/schema/topic.dart'; @@ -465,63 +468,61 @@ class _ContactHomeScreenState extends BaseStateFulWidgetState ), ), Expanded( - child: ListView.builder( - padding: EdgeInsets.only(bottom: 72), - itemCount: listItemViewCount, - itemBuilder: (context, index) { - int friendItemIndex = index - 1; - int topicItemIndex = index - searchFriendViewCount - 1; - int groupItemIndex = index - searchTopicViewCount - searchFriendViewCount - 1; - /*int strangerItemIndex = index - searchGroupViewCount - searchTopicViewCount - searchFriendViewCount - 1;*/ - - if (searchFriendViewCount > 0 && index >= friendStartIndex && index <= friendEndIndex) { - if (index == friendStartIndex) { - return Padding( - padding: const EdgeInsets.only(top: 12, bottom: 16, left: 16, right: 16), - child: Label( - '($searchFriendDataCount) ${Settings.locale((s) => s.friends, ctx: context)}', - type: LabelType.h3, - ), - ); - } - return _getFriendItemView(_searchFriends[friendItemIndex]); - } else if (searchTopicViewCount > 0 && index >= topicStartIndex && index <= topicEndIndex) { - if (index == topicStartIndex) { - return Padding( - padding: const EdgeInsets.only(top: 24, bottom: 16, left: 16, right: 16), - child: Label( - '($searchTopicDataCount) ${Settings.locale((s) => s.group_chat, ctx: context)}', - type: LabelType.h3, - ), - ); - } - return _getTopicItemView(_searchTopics[topicItemIndex]); - } else if (searchGroupViewCount > 0 && index >= groupStartIndex && index <= groupEndIndex) { - if (index == groupStartIndex) { - return Padding( - padding: const EdgeInsets.only(top: 24, bottom: 16, left: 16, right: 16), - child: Label( - '($searchGroupDataCount) ${Settings.locale((s) => s.group_chat, ctx: context)}', - type: LabelType.h3, - ), - ); - } - return _getGroupItemView(_searchGroups[groupItemIndex]); - } - /*else if (searchStrangerViewCount > 0 && index >= strangerStartIndex && index <= strangerEndIndex) { - if (index == strangerStartIndex) { - return Padding( - padding: const EdgeInsets.only(top: 24, bottom: 16, left: 16, right: 16), - child: Label( - '($searchStrangerDataCount) ${Settings.locale((s) => s.recent, ctx: context)}', - type: LabelType.h3, - ), - ); - } - return _getStrangerItemView(_searchStrangers[strangerItemIndex]); - }*/ - return SizedBox.shrink(); - }, + child: CustomScrollView( + slivers: [ + SliverToBoxAdapter( + child: _searchController.text.isNotEmpty ? _buildSearchByIdMenuBar() : _buildActionMenuBar(), + ), + SliverPadding( + padding: EdgeInsets.only(bottom: 72), + sliver: SliverList( + delegate: SliverChildBuilderDelegate( + (context, index) { + int friendItemIndex = index - 1; + int topicItemIndex = index - searchFriendViewCount - 1; + int groupItemIndex = index - searchTopicViewCount - searchFriendViewCount - 1; + + if (searchFriendViewCount > 0 && index >= friendStartIndex && index <= friendEndIndex) { + if (index == friendStartIndex) { + return Padding( + padding: const EdgeInsets.only(top: 12, bottom: 16, left: 16, right: 16), + child: Label( + '($searchFriendDataCount) ${Settings.locale((s) => s.friends, ctx: context)}', + type: LabelType.h3, + ), + ); + } + return _getFriendItemView(_searchFriends[friendItemIndex]); + } else if (searchTopicViewCount > 0 && index >= topicStartIndex && index <= topicEndIndex) { + if (index == topicStartIndex) { + return Padding( + padding: const EdgeInsets.only(top: 24, bottom: 16, left: 16, right: 16), + child: Label( + '($searchTopicDataCount) ${Settings.locale((s) => s.group_chat, ctx: context)}', + type: LabelType.h3, + ), + ); + } + return _getTopicItemView(_searchTopics[topicItemIndex]); + } else if (searchGroupViewCount > 0 && index >= groupStartIndex && index <= groupEndIndex) { + if (index == groupStartIndex) { + return Padding( + padding: const EdgeInsets.only(top: 24, bottom: 16, left: 16, right: 16), + child: Label( + '($searchGroupDataCount) ${Settings.locale((s) => s.group_chat, ctx: context)}', + type: LabelType.h3, + ), + ); + } + return _getGroupItemView(_searchGroups[groupItemIndex]); + } + return SizedBox.shrink(); + }, + childCount: listItemViewCount, + ), + ), + ), + ], ), ), ], @@ -773,4 +774,186 @@ class _ContactHomeScreenState extends BaseStateFulWidgetState ), ); } + + _buttonStyle({bool topRadius = true, bool botRadius = true, double topPad = 12, double botPad = 12}) { + return ButtonStyle( + backgroundColor: MaterialStateProperty.resolveWith((state) => application.theme.backgroundLightColor), + padding: MaterialStateProperty.resolveWith((states) => EdgeInsets.only(left: 16, right: 16, top: topPad, bottom: botPad)), + shape: MaterialStateProperty.resolveWith( + (states) => RoundedRectangleBorder( + borderRadius: BorderRadius.vertical( + top: topRadius ? Radius.circular(12) : Radius.zero, + bottom: botRadius ? Radius.circular(12) : Radius.zero, + ), + ), + ), + ); + } + + Widget _buildActionMenuBar() { + return Container( + padding: EdgeInsets.symmetric(horizontal: 16, vertical: 12), + child: Column( + children: [ + TextButton( + style: _buttonStyle(topRadius: true, botRadius: false, topPad: 15, botPad: 15), + onPressed: () async { + ContactSchema? validatedContact; + + String? address = await BottomDialog.of(Settings.appContext).showInput( + title: Settings.locale((s) => s.new_whisper, ctx: context), + inputTip: Settings.locale((s) => s.send_to, ctx: context), + inputHint: Settings.locale((s) => s.enter_or_select_a_user_pubkey, ctx: context), + contactSelect: true, + asyncValidator: (value) async { + if (value.isEmpty) { + return null; + } + + validatedContact = await contactCommon.resolveByAddress(value, canAdd: true); + if (validatedContact == null) { + return Settings.locale((s) => s.tip_address_not_found, ctx: context); + } + return null; + }, + ); + + if (address != null && address.isNotEmpty && validatedContact != null) { + await ChatMessagesScreen.go(context, validatedContact!); + } + }, + child: Row( + children: [ + Asset.iconSvg('user', color: application.theme.primaryColor, width: 24), + SizedBox(width: 10), + Label( + Settings.locale((s) => s.new_whisper, ctx: context), + type: LabelType.bodyRegular, + color: application.theme.fontColor1, + ), + Spacer(), + Asset.iconSvg( + 'right', + width: 24, + color: application.theme.fontColor2, + ), + ], + ), + ), + Divider(height: 0, color: application.theme.dividerColor), + TextButton( + style: _buttonStyle(topRadius: false, botRadius: false, topPad: 15, botPad: 15), + onPressed: () { + BottomDialog.of(Settings.appContext).showWithTitle( + height: Settings.screenHeight() * 0.8, + title: Settings.locale((s) => s.create_channel, ctx: context), + child: ChatTopicSearchLayout(), + ); + }, + child: Row( + children: [ + Asset.iconSvg('group', color: application.theme.primaryColor, width: 24), + SizedBox(width: 10), + Label( + Settings.locale((s) => s.new_public_group, ctx: context), + type: LabelType.bodyRegular, + color: application.theme.fontColor1, + ), + Spacer(), + Asset.iconSvg( + 'right', + width: 24, + color: application.theme.fontColor2, + ), + ], + ), + ), + Divider(height: 0, color: application.theme.dividerColor), + TextButton( + style: _buttonStyle(topRadius: false, botRadius: true, topPad: 15, botPad: 15), + onPressed: () { + BottomDialog.of(Settings.appContext).showWithTitle( + height: 300, + title: Settings.locale((s) => s.create_private_group, ctx: context), + child: CreatePrivateGroup(), + ); + }, + child: Row( + children: [ + Asset.iconSvg('lock', color: application.theme.primaryColor, width: 24), + SizedBox(width: 10), + Label( + Settings.locale((s) => s.new_private_group, ctx: context), + type: LabelType.bodyRegular, + color: application.theme.fontColor1, + ), + Spacer(), + Asset.iconSvg( + 'right', + width: 24, + color: application.theme.fontColor2, + ), + ], + ), + ), + ], + ), + ); + } + + Widget _buildSearchByIdMenuBar() { + return Container( + padding: EdgeInsets.symmetric(horizontal: 16, vertical: 12), + child: TextButton( + style: _buttonStyle(topRadius: true, botRadius: true, topPad: 15, botPad: 15), + onPressed: () async { + String searchText = _searchController.text.trim(); + if (searchText.isEmpty) { + Toast.show(Settings.locale((s) => s.search, ctx: context)); + return; + } + + Loading.show(); + try { + ContactSchema? validatedContact = await contactCommon.resolveByAddress(searchText, canAdd: true); + Loading.dismiss(); + + if (validatedContact == null) { + if (!mounted) return; + Toast.show(Settings.locale((s) => s.tip_address_not_found, ctx: context)); + return; + } + + if (!mounted) return; + await ChatMessagesScreen.go(context, validatedContact); + } catch (e, st) { + Loading.dismiss(); + handleError(e, st); + if (!mounted) return; + Toast.show(Settings.locale((s) => s.something_went_wrong, ctx: context)); + } + }, + child: Row( + children: [ + Asset.iconSvg('search', color: application.theme.primaryColor, width: 24), + SizedBox(width: 10), + Expanded( + child: Label( + '${Settings.locale((s) => s.search, ctx: context)} "${_searchController.text}"', + type: LabelType.bodyRegular, + color: application.theme.fontColor1, + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ), + Asset.iconSvg( + 'right', + width: 24, + color: application.theme.fontColor2, + ), + ], + ), + ), + ); + } } diff --git a/pubspec.yaml b/pubspec.yaml index afd9830a7..9807389a0 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -16,7 +16,7 @@ publish_to: 'none' # Remove this line if you wish to publish to pub.dev # Read more about iOS versioning at # https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html -version: 1.7.2+346 +version: 1.7.2+348 environment: sdk: ">=2.12.0 <3.0.0" From 5b0a98658ce6a580ff4afc8d7c3c0ec87e641732 Mon Sep 17 00:00:00 2001 From: Heron Date: Mon, 10 Nov 2025 15:58:24 +0800 Subject: [PATCH 31/47] Add search at session list screen --- ios/Runner.xcodeproj/project.pbxproj | 6 +- lib/common/message/session.dart | 4 + lib/screens/chat/session_list.dart | 187 ++++++++++++++++++++++----- lib/storages/session.dart | 140 ++++++++++++++++++++ pubspec.yaml | 2 +- 5 files changed, 305 insertions(+), 34 deletions(-) diff --git a/ios/Runner.xcodeproj/project.pbxproj b/ios/Runner.xcodeproj/project.pbxproj index afed0566d..284042ece 100644 --- a/ios/Runner.xcodeproj/project.pbxproj +++ b/ios/Runner.xcodeproj/project.pbxproj @@ -875,7 +875,7 @@ CODE_SIGN_ENTITLEMENTS = Runner/Runner.entitlements; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 347; + CURRENT_PROJECT_VERSION = 349; DEVELOPMENT_TEAM = 67P82ZQDAS; ENABLE_BITCODE = NO; INFOPLIST_FILE = Runner/Info.plist; @@ -1011,7 +1011,7 @@ CODE_SIGN_ENTITLEMENTS = Runner/Runner.entitlements; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 347; + CURRENT_PROJECT_VERSION = 349; DEVELOPMENT_TEAM = 67P82ZQDAS; ENABLE_BITCODE = NO; INFOPLIST_FILE = Runner/Info.plist; @@ -1039,7 +1039,7 @@ CODE_SIGN_ENTITLEMENTS = Runner/Runner.entitlements; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 347; + CURRENT_PROJECT_VERSION = 349; DEVELOPMENT_TEAM = 67P82ZQDAS; ENABLE_BITCODE = NO; INFOPLIST_FILE = Runner/Info.plist; diff --git a/lib/common/message/session.dart b/lib/common/message/session.dart index d5eb2b963..159c6bd0b 100644 --- a/lib/common/message/session.dart +++ b/lib/common/message/session.dart @@ -196,6 +196,10 @@ class SessionCommon with Tag { return SessionStorage.instance.queryListRecent(offset: offset, limit: limit); } + Future> queryListBySearch(String query) { + return SessionStorage.instance.queryListBySearch(query); + } + Future totalUnReadCount() { return SessionStorage.instance.querySumUnReadCount(); } diff --git a/lib/screens/chat/session_list.dart b/lib/screens/chat/session_list.dart index 22e83d426..4a3a203c8 100644 --- a/lib/screens/chat/session_list.dart +++ b/lib/screens/chat/session_list.dart @@ -13,7 +13,9 @@ import 'package:nmobile/components/dialog/modal.dart'; import 'package:nmobile/components/text/label.dart'; import 'package:nmobile/schema/contact.dart'; import 'package:nmobile/schema/message.dart'; +import 'package:nmobile/schema/private_group.dart'; import 'package:nmobile/schema/session.dart'; +import 'package:nmobile/schema/topic.dart'; import 'package:nmobile/screens/chat/messages.dart'; import 'package:nmobile/screens/chat/no_message.dart'; import 'package:nmobile/storages/settings.dart'; @@ -22,6 +24,8 @@ import 'package:nmobile/utils/logger.dart'; import 'package:nmobile/utils/parallel_queue.dart'; import 'package:nmobile/utils/util.dart'; +import '../../components/text/fixed_text_field.dart'; + class ChatSessionListLayout extends BaseStateFulWidget { ContactSchema current; @@ -41,6 +45,9 @@ class _ChatSessionListLayoutState extends BaseStateFulWidgetState error ? logger.w(log) : null); @@ -48,6 +55,8 @@ class _ChatSessionListLayoutState extends BaseStateFulWidgetState _sessionList = []; + List _filteredSessionList = []; + String _searchQuery = ""; int clientConnectStatus = ClientConnectStatus.connecting; bool clientConnectingVisible = false; @@ -62,6 +71,8 @@ class _ChatSessionListLayoutState extends BaseStateFulWidgetState a.isTop ? (b.isTop ? (b.lastMessageAt).compareTo((a.lastMessageAt)) : -1) : (b.isTop ? 1 : b.lastMessageAt.compareTo(a.lastMessageAt))); }); + _performSearch(_searchQuery); } Future _sessionDel(String targetId, int targetType) async { await _queue.add(() async { _sessionList = _sessionList.where((element) => !((element.targetId == targetId) && (element.type == targetType))).toList(); }); + _performSearch(_searchQuery); } Future _sessionQuery(String msgId) async { @@ -224,6 +254,20 @@ class _ChatSessionListLayoutState extends BaseStateFulWidgetState _performSearch(String query) async { + if (query.trim().isEmpty) { + setState(() { + _filteredSessionList = List.from(_sessionList); + }); + return; + } + + List filtered = await sessionCommon.queryListBySearch(query); + setState(() { + _filteredSessionList = filtered; + }); + } + _popItemMenu(SessionSchema item, int index) { showDialog( context: context, @@ -295,14 +339,20 @@ class _ChatSessionListLayoutState extends BaseStateFulWidgetState= _sessionList.length) return SizedBox.shrink(); - var session = _sessionList[index]; - return Column( - children: [ - ChatSessionItem( - session: session, - onTap: (who) { - ChatMessagesScreen.go(context, who).then((value) { - _refreshBadge(delayMs: 0); - }); - }, - onLongPress: (who) { - _popItemMenu(session, index); + return Column( + children: [ + Expanded( + flex: 0, + child: GestureDetector( + onTap: () {}, + child: Container( + padding: const EdgeInsets.only(left: 16, right: 16, top: 24, bottom: 12), + child: Container( + decoration: BoxDecoration( + color: application.theme.backgroundColor2, + borderRadius: BorderRadius.all(Radius.circular(8)), + ), + child: Row( + children: [ + Container( + width: 48, + height: 48, + alignment: Alignment.center, + child: Asset.iconSvg( + 'search', + color: application.theme.fontColor2, + ), + ), + Expanded( + child: FixedTextField( + controller: _searchController, + focusNode: _searchFocusNode, + onChanged: (val) { + _searchQuery = val; + _performSearch(val); + setState(() {}); + }, + style: TextStyle(fontSize: 14, height: 1.5), + decoration: InputDecoration( + hintText: Settings.locale((s) => s.search, ctx: context), + contentPadding: const EdgeInsets.only(left: 0, right: 16, top: 9, bottom: 9), + suffixIcon: _searchController.text.isNotEmpty + ? IconButton( + onPressed: () { + _searchController.clear(); + _searchQuery = ""; + _performSearch(""); + _searchFocusNode.unfocus(); + setState(() {}); + }, + icon: Asset.iconSvg( + 'close', + width: 16, + color: application.theme.fontColor2, + )) + : null, + suffixIconConstraints: BoxConstraints(minHeight: 24, minWidth: 24), + border: UnderlineInputBorder( + borderRadius: BorderRadius.all(Radius.circular(20)), + borderSide: const BorderSide(width: 0, style: BorderStyle.none), + ), + ), + ), + ), + ], + ), + ), + ), + ), + ), + Expanded( + child: GestureDetector( + onTap: () { + FocusScope.of(context).unfocus(); + }, + behavior: HitTestBehavior.translucent, + child: ListView.builder( + padding: EdgeInsets.only(bottom: 80 + Settings.screenHeight() * 0.05), + controller: _scrollController, + itemCount: _filteredSessionList.length, + itemBuilder: (BuildContext context, int index) { + if (index < 0 || index >= _filteredSessionList.length) return SizedBox.shrink(); + var session = _filteredSessionList[index]; + return Column( + children: [ + ChatSessionItem( + session: session, + onTap: (who) async { + FocusScope.of(context).unfocus(); + ChatMessagesScreen.go(context, who).then((value) { + _refreshBadge(delayMs: 0); + }); + }, + onLongPress: (who) { + _popItemMenu(session, index); + }, + ), + Divider(color: session.isTop ? application.theme.backgroundColor3.withAlpha(120) : application.theme.dividerColor, height: 0, indent: 70, endIndent: 12), + ], + ); }, ), - Divider(color: session.isTop ? application.theme.backgroundColor3.withAlpha(120) : application.theme.dividerColor, height: 0, indent: 70, endIndent: 12), - ], - ); - }, + ), + ), + ], ); } } diff --git a/lib/storages/session.dart b/lib/storages/session.dart index 36bfbff39..3768a7b30 100644 --- a/lib/storages/session.dart +++ b/lib/storages/session.dart @@ -9,6 +9,11 @@ import 'package:nmobile/utils/parallel_queue.dart'; import 'package:sentry_flutter/sentry_flutter.dart'; import 'package:sqflite_sqlcipher/sqflite.dart'; +import 'contact.dart'; +import 'message.dart'; +import 'private_group.dart'; +import 'topic.dart'; + class SessionStorage with Tag { // static String get tableName => 'Session'; static String get tableName => 'session_v7'; // v7 @@ -331,4 +336,139 @@ class SessionStorage with Tag { }) ?? false; } + + Future> queryListBySearch(String query) async { + if (query.trim().isEmpty) { + return await queryListRecent(offset: 0, limit: 1000); + } + + if (db?.isOpen != true) { + if (Settings.sentryEnable) { + Sentry.captureMessage("DB_SESSION CLOSED - queryListBySearch"); + } + return []; + } + + try { + String searchPattern = '%${query.trim()}%'; + String lowerPattern = searchPattern.toLowerCase(); + String lowerQuery = query.trim().toLowerCase(); + + // Get all sessions + List allSessions = []; + int offset = 0; + const int limit = 100; + while (true) { + List sessions = await queryListRecent(offset: offset, limit: limit); + if (sessions.isEmpty) break; + allSessions.addAll(sessions); + if (sessions.length < limit) break; + offset += limit; + } + + List filtered = []; + + for (var session in allSessions) { + bool matches = false; + + // Check last message content in database + if (!matches) { + try { + List>? msgResults = await db?.rawQuery( + 'SELECT * FROM ${MessageStorage.tableName} WHERE target_id = ? AND target_type = ? AND is_delete = 0 AND (LOWER(content) LIKE ? OR LOWER(type) LIKE ?) ORDER BY send_at DESC LIMIT 1', + [session.targetId, session.type, lowerPattern, lowerPattern], + ); + if (msgResults != null && msgResults.isNotEmpty) { + matches = true; + } + } catch (e) { + logger.w("$TAG - queryListBySearch - Search message error: $e"); + } + } + + // Check contact/topic/group name in database + if (!matches) { + if (session.type == SessionType.CONTACT) { + // Check contact name and address in database + try { + List>? contactResults = await db?.rawQuery( + 'SELECT * FROM ${ContactStorage.tableName} WHERE address = ? AND (LOWER(first_name) LIKE ? OR LOWER(last_name) LIKE ? OR LOWER(remark_name) LIKE ? OR LOWER(address) LIKE ?) LIMIT 1', + [session.targetId, lowerPattern, lowerPattern, lowerPattern, lowerPattern, lowerPattern], + ); + if (contactResults != null && contactResults.isNotEmpty) { + matches = true; + } else { + // Fallback to targetId + if (session.targetId.toLowerCase().contains(lowerQuery)) { + matches = true; + } + } + } catch (e) { + logger.w("$TAG - queryListBySearch - Search contact error: $e"); + // Fallback to targetId + if (session.targetId.toLowerCase().contains(lowerQuery)) { + matches = true; + } + } + } else if (session.type == SessionType.TOPIC) { + // Check topic name in database + try { + List>? topicResults = await db?.rawQuery( + 'SELECT * FROM ${TopicStorage.tableName} WHERE topic_id = ? AND LOWER(topic_id) LIKE ? LIMIT 1', + [session.targetId, lowerPattern], + ); + if (topicResults != null && topicResults.isNotEmpty) { + matches = true; + } else { + // Fallback to targetId + if (session.targetId.toLowerCase().contains(lowerQuery)) { + matches = true; + } + } + } catch (e) { + logger.w("$TAG - queryListBySearch - Search topic error: $e"); + // Fallback to targetId + if (session.targetId.toLowerCase().contains(lowerQuery)) { + matches = true; + } + } + } else if (session.type == SessionType.PRIVATE_GROUP) { + // Check group name in database + try { + List>? groupResults = await db?.rawQuery( + 'SELECT * FROM ${PrivateGroupStorage.tableName} WHERE group_id = ? AND LOWER(name) LIKE ? LIMIT 1', + [session.targetId, lowerPattern], + ); + if (groupResults != null && groupResults.isNotEmpty) { + matches = true; + } else { + // Fallback to targetId + if (session.targetId.toLowerCase().contains(lowerQuery)) { + matches = true; + } + } + } catch (e) { + logger.w("$TAG - queryListBySearch - Search group error: $e"); + // Fallback to targetId + if (session.targetId.toLowerCase().contains(lowerQuery)) { + matches = true; + } + } + } + } + + if (matches) { + filtered.add(session); + } + } + + // Sort filtered results + filtered.sort((a, b) => a.isTop ? (b.isTop ? (b.lastMessageAt).compareTo((a.lastMessageAt)) : -1) : (b.isTop ? 1 : b.lastMessageAt.compareTo(a.lastMessageAt))); + + return filtered; + } catch (e, st) { + handleError(e, st); + } + return []; + } } diff --git a/pubspec.yaml b/pubspec.yaml index 9807389a0..7267316c2 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -16,7 +16,7 @@ publish_to: 'none' # Remove this line if you wish to publish to pub.dev # Read more about iOS versioning at # https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html -version: 1.7.2+348 +version: 1.7.2+349 environment: sdk: ">=2.12.0 <3.0.0" From 50fcf5c74779e1763a442d0ecf39d7721ee36296 Mon Sep 17 00:00:00 2001 From: Heron Date: Mon, 10 Nov 2025 16:12:47 +0800 Subject: [PATCH 32/47] Add unfocus at menu --- ios/Runner.xcodeproj/project.pbxproj | 16 ++++++++++++---- lib/components/layout/nav.dart | 1 + pubspec.yaml | 2 +- 3 files changed, 14 insertions(+), 5 deletions(-) diff --git a/ios/Runner.xcodeproj/project.pbxproj b/ios/Runner.xcodeproj/project.pbxproj index 284042ece..da29c6d78 100644 --- a/ios/Runner.xcodeproj/project.pbxproj +++ b/ios/Runner.xcodeproj/project.pbxproj @@ -3,7 +3,7 @@ archiveVersion = 1; classes = { }; - objectVersion = 54; + objectVersion = 77; objects = { /* Begin PBXBuildFile section */ @@ -545,10 +545,14 @@ inputFileListPaths = ( "${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-resources-${CONFIGURATION}-input-files.xcfilelist", ); + inputPaths = ( + ); name = "[CP] Copy Pods Resources"; outputFileListPaths = ( "${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-resources-${CONFIGURATION}-output-files.xcfilelist", ); + outputPaths = ( + ); runOnlyForDeploymentPostprocessing = 0; shellPath = /bin/sh; shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-resources.sh\"\n"; @@ -577,10 +581,14 @@ inputFileListPaths = ( "${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks-${CONFIGURATION}-input-files.xcfilelist", ); + inputPaths = ( + ); name = "[CP] Embed Pods Frameworks"; outputFileListPaths = ( "${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks-${CONFIGURATION}-output-files.xcfilelist", ); + outputPaths = ( + ); runOnlyForDeploymentPostprocessing = 0; shellPath = /bin/sh; shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks.sh\"\n"; @@ -875,7 +883,7 @@ CODE_SIGN_ENTITLEMENTS = Runner/Runner.entitlements; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 349; + CURRENT_PROJECT_VERSION = 350; DEVELOPMENT_TEAM = 67P82ZQDAS; ENABLE_BITCODE = NO; INFOPLIST_FILE = Runner/Info.plist; @@ -1011,7 +1019,7 @@ CODE_SIGN_ENTITLEMENTS = Runner/Runner.entitlements; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 349; + CURRENT_PROJECT_VERSION = 350; DEVELOPMENT_TEAM = 67P82ZQDAS; ENABLE_BITCODE = NO; INFOPLIST_FILE = Runner/Info.plist; @@ -1039,7 +1047,7 @@ CODE_SIGN_ENTITLEMENTS = Runner/Runner.entitlements; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 349; + CURRENT_PROJECT_VERSION = 350; DEVELOPMENT_TEAM = 67P82ZQDAS; ENABLE_BITCODE = NO; INFOPLIST_FILE = Runner/Info.plist; diff --git a/lib/components/layout/nav.dart b/lib/components/layout/nav.dart index ac406f1b5..bb70a16f0 100644 --- a/lib/components/layout/nav.dart +++ b/lib/components/layout/nav.dart @@ -28,6 +28,7 @@ class _NavState extends BaseStateFulWidgetState