From a5684e197808de285da71f6e00e823440f8a02a1 Mon Sep 17 00:00:00 2001 From: John Maguire Date: Thu, 17 Nov 2022 16:46:06 -0500 Subject: [PATCH] Fix share on Android by moving to flutter share lib (#87) Co-authored-by: Nate Brown --- android/app/build.gradle | 2 +- .../net/defined/mobile_nebula/MainActivity.kt | 3 - .../kotlin/net/defined/mobile_nebula/Share.kt | 134 ---------------- ios/Podfile.lock | 6 + ios/Runner.xcodeproj/project.pbxproj | 6 +- ios/Runner/AppDelegate.swift | 3 - ios/Runner/Share.swift | 150 ------------------ lib/services/share.dart | 53 +++---- pubspec.lock | 40 ++++- pubspec.yaml | 1 + 10 files changed, 67 insertions(+), 331 deletions(-) delete mode 100644 android/app/src/main/kotlin/net/defined/mobile_nebula/Share.kt delete mode 100644 ios/Runner/Share.swift diff --git a/android/app/build.gradle b/android/app/build.gradle index 5d5610f..26be35c 100644 --- a/android/app/build.gradle +++ b/android/app/build.gradle @@ -26,7 +26,7 @@ apply plugin: 'kotlin-android' apply from: "$flutterRoot/packages/flutter_tools/gradle/flutter.gradle" android { - compileSdkVersion flutter.compileSdkVersion + compileSdkVersion 33 ndkVersion flutter.ndkVersion compileOptions { diff --git a/android/app/src/main/kotlin/net/defined/mobile_nebula/MainActivity.kt b/android/app/src/main/kotlin/net/defined/mobile_nebula/MainActivity.kt index 5e6cd65..d2fd632 100644 --- a/android/app/src/main/kotlin/net/defined/mobile_nebula/MainActivity.kt +++ b/android/app/src/main/kotlin/net/defined/mobile_nebula/MainActivity.kt @@ -84,9 +84,6 @@ class MainActivity: FlutterActivity() { "active.setRemoteForTunnel" -> activeSetRemoteForTunnel(call, result) "active.closeTunnel" -> activeCloseTunnel(call, result) - "share" -> Share.share(call, result) - "shareFile" -> Share.shareFile(call, result) - else -> result.notImplemented() } } diff --git a/android/app/src/main/kotlin/net/defined/mobile_nebula/Share.kt b/android/app/src/main/kotlin/net/defined/mobile_nebula/Share.kt deleted file mode 100644 index 04fde5d..0000000 --- a/android/app/src/main/kotlin/net/defined/mobile_nebula/Share.kt +++ /dev/null @@ -1,134 +0,0 @@ -package net.defined.mobile_nebula - -import android.app.PendingIntent -import android.content.* -import android.content.pm.PackageManager -import android.content.pm.ResolveInfo -import android.util.Log -import androidx.core.content.FileProvider -import io.flutter.plugin.common.MethodCall -import io.flutter.plugin.common.MethodChannel -import java.io.File - -class Share { - companion object { - fun share(call: MethodCall, result: MethodChannel.Result) { - val title = call.argument("title") - val text = call.argument("text") - val filename = call.argument("filename") - - if (filename == null || filename.isEmpty()) { - return result.error("filename was not provided", null, null) - } - - try { - val context = MainActivity!!.getContext()!! - val cacheDir = context.cacheDir.resolve("share") - cacheDir.deleteRecursively() - cacheDir.mkdir() - val newFile = cacheDir.resolve(filename!!) - newFile.delete() - newFile.writeText(text ?: "") - pop(title, newFile, result) - - } catch (err: Exception) { - Log.println(Log.ERROR, "", "Share: Error") - result.error(err.message ?: "Unknown error", null, null) - } - } - - fun shareFile(call: MethodCall, result: MethodChannel.Result) { - val title = call.argument("title") - val filename = call.argument("filename") - val filePath = call.argument("filePath") - - if (filename == null || filename.isEmpty()) { - result.error("filename was not provided", null, null) - return - } - - if (filePath == null || filePath.isEmpty()) { - result.error("filePath was not provided", null, null) - return - } - - val file = File(filePath) - - try { - val context = MainActivity!!.getContext()!! - val cacheDir = context.cacheDir.resolve("share") - cacheDir.deleteRecursively() - cacheDir.mkdir() - val newFile = cacheDir.resolve(filename!!) - newFile.delete() - file.copyTo(newFile) - - pop(title, newFile, result) - - } catch (err: Exception) { - Log.println(Log.ERROR, "", "Share: Error") - result.error(err.message ?: "Unknown error", null, null) - } - } - - private fun pop(title: String?, file: File, result: MethodChannel.Result) { - if (title == null || title.isEmpty()) { - result.error("title was not provided", null, null) - return - } - - try { - val context = MainActivity!!.getContext()!! - - val fileUri = FileProvider.getUriForFile(context, context.applicationContext.packageName + ".provider", file) - val intent = Intent() - - intent.flags = Intent.FLAG_ACTIVITY_CLEAR_TOP - intent.flags = Intent.FLAG_ACTIVITY_NEW_TASK - intent.action = Intent.ACTION_SEND - intent.type = "text/*" - - intent.putExtra(Intent.EXTRA_SUBJECT, title) - intent.putExtra(Intent.EXTRA_STREAM, fileUri) - intent.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) - - val receiver = Intent(context, ShareReceiver::class.java) - receiver.putExtra(Intent.EXTRA_TEXT, file) - val pendingIntent = PendingIntent.getBroadcast(context, 0, receiver, PendingIntent.FLAG_UPDATE_CURRENT) - - val chooserIntent = Intent.createChooser(intent, title, pendingIntent.intentSender) - val resInfoList: List = context.packageManager.queryIntentActivities(chooserIntent, PackageManager.MATCH_DEFAULT_ONLY) - for (resolveInfo in resInfoList) { - val packageName: String = resolveInfo.activityInfo.packageName - context.grantUriPermission(packageName, fileUri, Intent.FLAG_GRANT_READ_URI_PERMISSION) - } - - context.startActivity(chooserIntent) - - } catch (err: Exception) { - Log.println(Log.ERROR, "", "Share: Error") - return result.error(err.message ?: "Unknown error", null, null) - } - - result.success(true) - } - } -} - -class ShareReceiver : BroadcastReceiver() { - override fun onReceive(context: Context?, intent: Intent?) { - if (intent == null) { - return - } - - val res = intent.extras!!.get(Intent.EXTRA_CHOSEN_COMPONENT) as? ComponentName ?: return - when (res.className) { - "org.chromium.arc.intent_helper.SendTextToClipboardActivity" -> { - val file = intent.extras!![Intent.EXTRA_TEXT] as? File ?: return - val clipboard = context?.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager - - clipboard.setPrimaryClip(ClipData.newPlainText("", file.readText())) - } - } - } -} \ No newline at end of file diff --git a/ios/Podfile.lock b/ios/Podfile.lock index 2537f26..eca2ecf 100644 --- a/ios/Podfile.lock +++ b/ios/Podfile.lock @@ -43,6 +43,8 @@ PODS: - SDWebImage (5.13.3): - SDWebImage/Core (= 5.13.3) - SDWebImage/Core (5.13.3) + - share_plus (0.0.1): + - Flutter - SwiftyGif (5.4.3) - SwiftyJSON (5.0.1) - url_launcher_ios (0.0.1): @@ -54,6 +56,7 @@ DEPENDENCIES: - flutter_barcode_scanner (from `.symlinks/plugins/flutter_barcode_scanner/ios`) - package_info (from `.symlinks/plugins/package_info/ios`) - path_provider_ios (from `.symlinks/plugins/path_provider_ios/ios`) + - share_plus (from `.symlinks/plugins/share_plus/ios`) - SwiftyJSON (~> 5.0) - url_launcher_ios (from `.symlinks/plugins/url_launcher_ios/ios`) @@ -76,6 +79,8 @@ EXTERNAL SOURCES: :path: ".symlinks/plugins/package_info/ios" path_provider_ios: :path: ".symlinks/plugins/path_provider_ios/ios" + share_plus: + :path: ".symlinks/plugins/share_plus/ios" url_launcher_ios: :path: ".symlinks/plugins/url_launcher_ios/ios" @@ -88,6 +93,7 @@ SPEC CHECKSUMS: package_info: 873975fc26034f0b863a300ad47e7f1ac6c7ec62 path_provider_ios: 7d7ce634493af4477d156294792024ec3485acd5 SDWebImage: af5bbffef2cde09f148d826f9733dcde1a9414cd + share_plus: 056a1e8ac890df3e33cb503afffaf1e9b4fbae68 SwiftyGif: 6c3eafd0ce693cad58bb63d2b2fb9bacb8552780 SwiftyJSON: 2f33a42c6fbc52764d96f13368585094bfd8aa5e url_launcher_ios: 02f1989d4e14e998335b02b67a7590fa34f971af diff --git a/ios/Runner.xcodeproj/project.pbxproj b/ios/Runner.xcodeproj/project.pbxproj index ae628f4..4f5ee6d 100644 --- a/ios/Runner.xcodeproj/project.pbxproj +++ b/ios/Runner.xcodeproj/project.pbxproj @@ -22,7 +22,6 @@ 43AA89572444DA6500EDC39C /* PacketTunnelProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = 43AA89562444DA6500EDC39C /* PacketTunnelProvider.swift */; }; 43AA895C2444DA6500EDC39C /* NebulaNetworkExtension.appex in Embed App Extensions */ = {isa = PBXBuildFile; fileRef = 43AA89542444DA6500EDC39C /* NebulaNetworkExtension.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; }; 43AA89622444DAA500EDC39C /* NetworkExtension.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 43AA894E2444D8BC00EDC39C /* NetworkExtension.framework */; }; - 43AD63F424EB3802000FB47E /* Share.swift in Sources */ = {isa = PBXBuildFile; fileRef = 43AD63F324EB3802000FB47E /* Share.swift */; }; 43ED87842912D0DD004DAFC5 /* DNUpdate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 43ED87832912D0DD004DAFC5 /* DNUpdate.swift */; }; 43ED87852912D0DD004DAFC5 /* DNUpdate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 43ED87832912D0DD004DAFC5 /* DNUpdate.swift */; }; 4CF2F06A02A63B862C9F6F03 /* Pods_Runner.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 384887B4785D38431E800D3A /* Pods_Runner.framework */; }; @@ -89,7 +88,6 @@ 43AA89562444DA6500EDC39C /* PacketTunnelProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PacketTunnelProvider.swift; sourceTree = ""; }; 43AA89582444DA6500EDC39C /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; 43AA89592444DA6500EDC39C /* NebulaNetworkExtension.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = NebulaNetworkExtension.entitlements; sourceTree = ""; }; - 43AD63F324EB3802000FB47E /* Share.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Share.swift; sourceTree = ""; }; 43B66ECA245A0C8400B18C36 /* CoreFoundation.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = CoreFoundation.framework; path = System/Library/Frameworks/CoreFoundation.framework; sourceTree = SDKROOT; }; 43B66ECC245A146300B18C36 /* Foundation.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = Foundation.framework; path = System/Library/Frameworks/Foundation.framework; sourceTree = SDKROOT; }; 43ED87832912D0DD004DAFC5 /* DNUpdate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DNUpdate.swift; sourceTree = ""; }; @@ -210,7 +208,6 @@ 74858FAE1ED2DC5600515810 /* AppDelegate.swift */, 74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */, 43871C9C2444E2EC004F9075 /* Sites.swift */, - 43AD63F324EB3802000FB47E /* Share.swift */, 43ED87832912D0DD004DAFC5 /* DNUpdate.swift */, BE45F625291AEAB300902884 /* PackageInfo.swift */, BE5BC105291C41E600B6FE5B /* APIClient.swift */, @@ -362,6 +359,7 @@ "${BUILT_PRODUCTS_DIR}/flutter_barcode_scanner/flutter_barcode_scanner.framework", "${BUILT_PRODUCTS_DIR}/package_info/package_info.framework", "${BUILT_PRODUCTS_DIR}/path_provider_ios/path_provider_ios.framework", + "${BUILT_PRODUCTS_DIR}/share_plus/share_plus.framework", "${BUILT_PRODUCTS_DIR}/url_launcher_ios/url_launcher_ios.framework", ); name = "[CP] Embed Pods Frameworks"; @@ -375,6 +373,7 @@ "${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/flutter_barcode_scanner.framework", "${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/package_info.framework", "${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/path_provider_ios.framework", + "${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/share_plus.framework", "${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/url_launcher_ios.framework", ); runOnlyForDeploymentPostprocessing = 0; @@ -476,7 +475,6 @@ buildActionMask = 2147483647; files = ( 74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */, - 43AD63F424EB3802000FB47E /* Share.swift in Sources */, 432D0E3E291C562200752563 /* SiteList.swift in Sources */, 43871C9D2444E2EC004F9075 /* Sites.swift in Sources */, BE5BC106291C41E600B6FE5B /* APIClient.swift in Sources */, diff --git a/ios/Runner/AppDelegate.swift b/ios/Runner/AppDelegate.swift index d68c77f..2f96515 100644 --- a/ios/Runner/AppDelegate.swift +++ b/ios/Runner/AppDelegate.swift @@ -68,9 +68,6 @@ func MissingArgumentError(message: String, details: Any?) -> FlutterError { case "active.setRemoteForTunnel": self.vpnRequest(command: "setRemoteForTunnel", arguments: call.arguments, result: result) case "active.closeTunnel": self.vpnRequest(command: "closeTunnel", arguments: call.arguments, result: result) - case "share": Share.share(call: call, result: result) - case "shareFile": Share.shareFile(call: call, result: result) - default: result(FlutterMethodNotImplemented) } diff --git a/ios/Runner/Share.swift b/ios/Runner/Share.swift deleted file mode 100644 index 730f69d..0000000 --- a/ios/Runner/Share.swift +++ /dev/null @@ -1,150 +0,0 @@ -// Basis of this code comes from https://github.com/lubritto/flutter_share - -import Flutter -import UIKit - -public class Share { - public static func share(call: FlutterMethodCall, result: @escaping FlutterResult) { - let args = call.arguments as? [String: Any?] - - let title = args!["title"] as? String - let text = args!["text"] as? String - let filename = args!["filename"] as? String - let tmpDirURL = FileManager.default.temporaryDirectory - - if (filename == nil || filename!.isEmpty) { - return result(false) - } - - let tmpFile = tmpDirURL.appendingPathComponent(filename!) - do { - try text?.write(to: tmpFile, atomically: true, encoding: .utf8) - } catch { - //TODO: return error - return result(false) - } - - pop(title: title, file: tmpFile) { pass in - let fm = FileManager() - do { - try fm.removeItem(at: tmpFile) - } catch {} - - return result(pass) - } - } - - public static func shareFile(call: FlutterMethodCall, result: @escaping FlutterResult) { - let args = call.arguments as? [String: Any?] - - let title = args!["title"] as? String - let filePath = args!["filePath"] as? String - let filename = args!["filename"] as? String - - if (filePath == nil || filePath!.isEmpty) { - return result(false) - } - - var tmpFile: URL? - let fm = FileManager() - var realPath = URL(fileURLWithPath: filePath!) - - if (filename != nil && !filename!.isEmpty) { - tmpFile = FileManager.default.temporaryDirectory.appendingPathComponent(filename!) - - do { - try fm.linkItem(at: URL(fileURLWithPath: filePath!), to: tmpFile!) - } catch { - //TODO: return error - return result(false) - } - - realPath = tmpFile! - } - - pop(title: title, file: realPath) { pass in - if (tmpFile != nil) { - do { - try fm.removeItem(at: tmpFile!) - } catch {} - } - result(pass) - } - } - - private static func pop(title: String?, file: URL, completion: @escaping ((Bool) -> Void)) { - if (title == nil || title!.isEmpty) { - return completion(false) - } - - let activityViewController = UIActivityViewController(activityItems: [ShareCopy(file: file)], applicationActivities: nil) - - activityViewController.completionWithItemsHandler = {(activityType: UIActivity.ActivityType?, completed: Bool, returnedItems: [Any]?, error: Error?) in - completion(true) - } - - // Subject - activityViewController.setValue(title, forKeyPath: "subject") - - // For iPads, fix issue where Exception is thrown by using a popup instead - if UIDevice.current.userInterfaceIdiom == .pad { - activityViewController.popoverPresentationController?.sourceView = UIApplication.topViewController()?.view - if let view = UIApplication.topViewController()?.view { - activityViewController.popoverPresentationController?.permittedArrowDirections = [] - activityViewController.popoverPresentationController?.sourceRect = CGRect(x: view.bounds.midX, y: view.bounds.midY, width: 0, height: 0) - } - } - - DispatchQueue.main.async { - UIApplication.topViewController()?.present(activityViewController, animated: true) - } - } -} - -extension UIApplication { - class func topViewController(controller: UIViewController? = UIApplication.shared.keyWindow?.rootViewController) -> UIViewController? { - if let navigationController = controller as? UINavigationController { - return topViewController(controller: navigationController.visibleViewController) - } - if let tabController = controller as? UITabBarController { - if let selected = tabController.selectedViewController { - return topViewController(controller: selected) - } - } - if let presented = controller?.presentedViewController { - return topViewController(controller: presented) - } - return controller - } -} - -class ShareCopy: UIActivityItemProvider { - private let file: URL - private let content: String - - init(file: URL) { - self.file = file - do { - self.content = try String.init(contentsOf: file) - } catch { - self.content = "Error" - } - - // the type of the placeholder item is used to - // display correct activity types by UIActivityControler - super.init(placeholderItem: self.content) - } - - override var item: Any { - get { - guard let activityType = activityType else { - return file - } - - switch activityType { - case .copyToPasteboard: return content - default: return file - } - } - } -} diff --git a/lib/services/share.dart b/lib/services/share.dart index ceddc76..3f7d0bb 100644 --- a/lib/services/share.dart +++ b/lib/services/share.dart @@ -1,14 +1,12 @@ -// This code comes from https://github.com/lubritto/flutter_share with bugfixes for ipad import 'dart:async'; +import 'dart:io'; -import 'package:flutter/cupertino.dart'; -import 'package:flutter/material.dart'; -import 'package:flutter/services.dart'; +import 'package:path_provider/path_provider.dart'; +import 'package:share_plus/share_plus.dart' as sp; +import 'package:path/path.dart' as p; class Share { - static const _channel = MethodChannel('net.defined.mobileNebula/NebulaVpnService'); - - /// Shares a string of text + /// Transforms a string of text into a file and shares that file /// - title: Title of message or subject if sending an email /// - text: The text to share /// - filename: The filename to use if sending over airdrop for example @@ -21,17 +19,19 @@ class Share { assert(text.isNotEmpty); assert(filename.isNotEmpty); - if (title.isEmpty) { - throw FlutterError('Title cannot be empty'); + final tmpDir = await getTemporaryDirectory(); + final file = File(p.join(tmpDir.path, filename)); + var res = false; + + try { + file.writeAsStringSync(text, flush: true); + res = await Share.shareFile(title: title, filePath: file.path); + } catch (err) { + // Ignoring file write errors } - final bool success = await _channel.invokeMethod('share', { - 'title': title, - 'text': text, - 'filename': filename, - }); - - return success; + file.delete(); + return res; } /// Shares a local file @@ -41,23 +41,16 @@ class Share { static Future shareFile({ required String title, required String filePath, - String? filename, + String? filename }) async { assert(title.isNotEmpty); assert(filePath.isNotEmpty); - if (title.isEmpty) { - throw FlutterError('Title cannot be empty'); - } else if (filePath.isEmpty) { - throw FlutterError('FilePath cannot be empty'); - } - - final bool success = await _channel.invokeMethod('shareFile', { - 'title': title, - 'filePath': filePath, - 'filename': filename, - }); - - return success; + //NOTE: the filename used to specify the name of the file in gmail/slack/etc but no longer works that way + // If we want to support that again we will need to save the file to a temporary directory, share that, + // and then delete it + final xFile = sp.XFile(filePath, name: filename); + final result = await sp.Share.shareXFiles([xFile], subject: title); + return result.status == sp.ShareResultStatus.success; } } diff --git a/pubspec.lock b/pubspec.lock index 6296b8b..0be5c11 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -36,6 +36,13 @@ packages: url: "https://pub.dartlang.org" source: hosted version: "1.16.0" + cross_file: + dependency: transitive + description: + name: cross_file + url: "https://pub.dartlang.org" + source: hosted + version: "0.3.3+2" crypto: dependency: transitive description: @@ -77,7 +84,7 @@ packages: name: file_picker url: "https://pub.dartlang.org" source: hosted - version: "5.0.1" + version: "5.2.2" flutter: dependency: "direct main" description: flutter @@ -156,6 +163,13 @@ packages: url: "https://pub.dartlang.org" source: hosted version: "1.8.0" + mime: + dependency: transitive + description: + name: mime + url: "https://pub.dartlang.org" + source: hosted + version: "1.0.2" package_info: dependency: "direct main" description: @@ -268,6 +282,20 @@ packages: url: "https://pub.dartlang.org" source: hosted version: "2.0.0" + share_plus: + dependency: "direct main" + description: + name: share_plus + url: "https://pub.dartlang.org" + source: hosted + version: "6.3.0" + share_plus_platform_interface: + dependency: transitive + description: + name: share_plus_platform_interface + url: "https://pub.dartlang.org" + source: hosted + version: "3.2.0" sky_engine: dependency: transitive description: flutter @@ -349,7 +377,7 @@ packages: name: url_launcher_linux url: "https://pub.dartlang.org" source: hosted - version: "2.0.2" + version: "3.0.1" url_launcher_macos: dependency: transitive description: @@ -370,21 +398,21 @@ packages: name: url_launcher_web url: "https://pub.dartlang.org" source: hosted - version: "2.0.5" + version: "2.0.13" url_launcher_windows: dependency: transitive description: name: url_launcher_windows url: "https://pub.dartlang.org" source: hosted - version: "2.0.2" + version: "3.0.1" uuid: dependency: "direct main" description: name: uuid url: "https://pub.dartlang.org" source: hosted - version: "3.0.5" + version: "3.0.7" vector_math: dependency: transitive description: @@ -398,7 +426,7 @@ packages: name: win32 url: "https://pub.dartlang.org" source: hosted - version: "2.7.0" + version: "3.1.1" xdg_directories: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index 8518b52..c6db6ec 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -33,6 +33,7 @@ dependencies: flutter_barcode_scanner: ^2.0.0 flutter_svg: ^1.1.5 intl: ^0.17.0 + share_plus: ^6.3.0 dev_dependencies: flutter_test: