From 4050f556400aaec5515231578512cf1a6b990110 Mon Sep 17 00:00:00 2001 From: Kingkor Roy Tirtho Date: Mon, 18 Dec 2023 22:12:16 +0600 Subject: [PATCH] feat: Deep link support (#950) * feat: add deep link support * feat(android): add intent share support * chore: untranslated msg for it locale --- android/app/src/main/AndroidManifest.xml | 26 +++++- lib/collections/initializers.dart | 25 +++++ lib/hooks/configurators/use_deep_linking.dart | 93 +++++++++++++++++++ lib/main.dart | 10 +- linux/flutter/generated_plugin_registrant.cc | 4 + linux/flutter/generated_plugins.cmake | 1 + linux/my_application.cc | 13 ++- linux/packaging/appimage/make_config.yaml | 3 + linux/packaging/deb/make_config.yaml | 3 + linux/packaging/rpm/make_config.yaml | 3 + macos/Flutter/GeneratedPluginRegistrant.swift | 2 + macos/Runner/Info.plist | 13 +++ pubspec.lock | 30 +++++- pubspec.yaml | 3 + untranslated_messages.json | 6 ++ .../flutter/generated_plugin_registrant.cc | 3 + windows/flutter/generated_plugins.cmake | 1 + windows/runner/win32_window.cpp | 40 ++++++++ windows/runner/win32_window.h | 4 + 19 files changed, 273 insertions(+), 10 deletions(-) create mode 100644 lib/collections/initializers.dart create mode 100644 lib/hooks/configurators/use_deep_linking.dart diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index a3f1390a3..5ab7a0b51 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -27,7 +27,7 @@ + + + + + + + + + + + + + + + + + + + + + diff --git a/lib/collections/initializers.dart b/lib/collections/initializers.dart new file mode 100644 index 000000000..9627de1c3 --- /dev/null +++ b/lib/collections/initializers.dart @@ -0,0 +1,25 @@ +import 'dart:io'; +import 'package:flutter_desktop_tools/flutter_desktop_tools.dart'; +import 'package:win32_registry/win32_registry.dart'; + +Future registerWindowsScheme(String scheme) async { + if (!DesktopTools.platform.isWindows) return; + String appPath = Platform.resolvedExecutable; + + String protocolRegKey = 'Software\\Classes\\$scheme'; + RegistryValue protocolRegValue = const RegistryValue( + 'URL Protocol', + RegistryValueType.string, + '', + ); + String protocolCmdRegKey = 'shell\\open\\command'; + RegistryValue protocolCmdRegValue = RegistryValue( + '', + RegistryValueType.string, + '"$appPath" "%1"', + ); + + final regKey = Registry.currentUser.createKey(protocolRegKey); + regKey.createValue(protocolRegValue); + regKey.createKey(protocolCmdRegKey).createValue(protocolCmdRegValue); +} diff --git a/lib/hooks/configurators/use_deep_linking.dart b/lib/hooks/configurators/use_deep_linking.dart new file mode 100644 index 000000000..546ab2e8b --- /dev/null +++ b/lib/hooks/configurators/use_deep_linking.dart @@ -0,0 +1,93 @@ +import 'package:app_links/app_links.dart'; +import 'package:fl_query_hooks/fl_query_hooks.dart'; +import 'package:flutter_hooks/flutter_hooks.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:spotify/spotify.dart'; +import 'package:spotube/collections/routes.dart'; +import 'package:spotube/provider/spotify_provider.dart'; +import 'package:flutter_sharing_intent/flutter_sharing_intent.dart'; +import 'package:flutter_sharing_intent/model/sharing_file.dart'; + +void useDeepLinking(WidgetRef ref) { + // single instance no worries + final appLinks = AppLinks(); + final spotify = ref.watch(spotifyProvider); + final queryClient = useQueryClient(); + + useEffect(() { + void uriListener(List files) async { + for (final file in files) { + if (file.type != SharedMediaType.URL) continue; + final url = Uri.parse(file.value!); + if (url.pathSegments.length != 2) continue; + + switch (url.pathSegments.first) { + case "album": + router.push( + "/album/${url.pathSegments.last}", + extra: await queryClient.fetchQuery( + "album/${url.pathSegments.last}", + () => spotify.albums.get(url.pathSegments.last), + ), + ); + break; + case "artist": + router.push("/artist/${url.pathSegments.last}"); + break; + case "playlist": + router.push( + "/playlist/${url.pathSegments.last}", + extra: await queryClient.fetchQuery( + "playlist/${url.pathSegments.last}", + () => spotify.playlists.get(url.pathSegments.last), + ), + ); + break; + default: + break; + } + } + } + + FlutterSharingIntent.instance.getInitialSharing().then(uriListener); + + final mediaStream = + FlutterSharingIntent.instance.getMediaStream().listen(uriListener); + + final subscription = appLinks.allStringLinkStream.listen((uri) async { + final startSegment = uri.split(":").take(2).join(":"); + final endSegment = uri.split(":").last; + + switch (startSegment) { + case "spotify:album": + await router.push( + "/album/$endSegment", + extra: await queryClient.fetchQuery( + "album/$endSegment", + () => spotify.albums.get(endSegment), + ), + ); + break; + case "spotify:artist": + await router.push("/artist/$endSegment"); + break; + case "spotify:playlist": + await router.push( + "/playlist/$endSegment", + extra: await queryClient.fetchQuery( + "playlist/$endSegment", + () => spotify.playlists.get(endSegment), + ), + ); + break; + default: + break; + } + }); + + return () { + mediaStream.cancel(); + subscription.cancel(); + }; + }, [spotify, queryClient]); +} diff --git a/lib/main.dart b/lib/main.dart index 91ec789d9..052e68092 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -13,9 +13,11 @@ import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:media_kit/media_kit.dart'; import 'package:metadata_god/metadata_god.dart'; import 'package:shared_preferences/shared_preferences.dart'; +import 'package:spotube/collections/initializers.dart'; import 'package:spotube/collections/routes.dart'; import 'package:spotube/collections/intents.dart'; import 'package:spotube/hooks/configurators/use_close_behavior.dart'; +import 'package:spotube/hooks/configurators/use_deep_linking.dart'; import 'package:spotube/hooks/configurators/use_disable_battery_optimizations.dart'; import 'package:spotube/hooks/configurators/use_get_storage_perms.dart'; import 'package:spotube/l10n/l10n.dart'; @@ -41,6 +43,8 @@ Future main(List rawArgs) async { final widgetsBinding = WidgetsFlutterBinding.ensureInitialized(); + await registerWindowsScheme("spotify"); + FlutterNativeSplash.preserve(widgetsBinding: widgetsBinding); MediaKit.ensureInitialized(); @@ -181,8 +185,11 @@ class SpotubeState extends ConsumerState { final paletteColor = ref.watch(paletteProvider.select((s) => s?.dominantColor?.color)); + useDisableBatteryOptimizations(); useInitSysTray(ref); + useDeepLinking(ref); useCloseBehavior(ref); + useGetStoragePermissions(ref); useEffect(() { FlutterNativeSplash.remove(); @@ -193,9 +200,6 @@ class SpotubeState extends ConsumerState { }; }, []); - useDisableBatteryOptimizations(); - useGetStoragePermissions(ref); - final lightTheme = useMemoized( () => theme(paletteColor ?? accentMaterialColor, Brightness.light, false), [paletteColor, accentMaterialColor], diff --git a/linux/flutter/generated_plugin_registrant.cc b/linux/flutter/generated_plugin_registrant.cc index a07f7f9b4..c69c17c01 100644 --- a/linux/flutter/generated_plugin_registrant.cc +++ b/linux/flutter/generated_plugin_registrant.cc @@ -9,6 +9,7 @@ #include #include #include +#include #include #include #include @@ -28,6 +29,9 @@ void fl_register_plugins(FlPluginRegistry* registry) { g_autoptr(FlPluginRegistrar) flutter_secure_storage_linux_registrar = fl_plugin_registry_get_registrar_for_plugin(registry, "FlutterSecureStorageLinuxPlugin"); flutter_secure_storage_linux_plugin_register_with_registrar(flutter_secure_storage_linux_registrar); + g_autoptr(FlPluginRegistrar) gtk_registrar = + fl_plugin_registry_get_registrar_for_plugin(registry, "GtkPlugin"); + gtk_plugin_register_with_registrar(gtk_registrar); g_autoptr(FlPluginRegistrar) local_notifier_registrar = fl_plugin_registry_get_registrar_for_plugin(registry, "LocalNotifierPlugin"); local_notifier_plugin_register_with_registrar(local_notifier_registrar); diff --git a/linux/flutter/generated_plugins.cmake b/linux/flutter/generated_plugins.cmake index 97d541b3d..a4487f4d0 100644 --- a/linux/flutter/generated_plugins.cmake +++ b/linux/flutter/generated_plugins.cmake @@ -6,6 +6,7 @@ list(APPEND FLUTTER_PLUGIN_LIST dart_discord_rpc file_selector_linux flutter_secure_storage_linux + gtk local_notifier media_kit_libs_linux screen_retriever diff --git a/linux/my_application.cc b/linux/my_application.cc index 759285af3..d1ac5d124 100644 --- a/linux/my_application.cc +++ b/linux/my_application.cc @@ -17,6 +17,13 @@ G_DEFINE_TYPE(MyApplication, my_application, GTK_TYPE_APPLICATION) // Implements GApplication::activate. static void my_application_activate(GApplication* application) { MyApplication* self = MY_APPLICATION(application); + + GList* windows = gtk_application_get_windows(GTK_APPLICATION(application)); + if (windows) { + gtk_window_present(GTK_WINDOW(windows->data)); + return; + } + GtkWindow* window = GTK_WINDOW(gtk_application_window_new(GTK_APPLICATION(application))); @@ -78,7 +85,7 @@ static gboolean my_application_local_command_line(GApplication* application, gch g_application_activate(application); *exit_status = 0; - return TRUE; + return FALSE; } // Implements GObject::dispose. @@ -98,7 +105,7 @@ static void my_application_init(MyApplication* self) {} MyApplication* my_application_new() { return MY_APPLICATION(g_object_new(my_application_get_type(), - "application-id", APPLICATION_ID, - "flags", G_APPLICATION_NON_UNIQUE, + "com.github.KRTirtho.Spotube", APPLICATION_ID, + "flags", G_APPLICATION_HANDLES_COMMAND_LINE | G_APPLICATION_HANDLES_OPEN, nullptr)); } diff --git a/linux/packaging/appimage/make_config.yaml b/linux/packaging/appimage/make_config.yaml index 68e36df72..c7332ea28 100644 --- a/linux/packaging/appimage/make_config.yaml +++ b/linux/packaging/appimage/make_config.yaml @@ -11,3 +11,6 @@ keywords: generic_name: Music Streaming Application categories: - Music + +supported_mime_type: + - x-scheme-handler/spotify diff --git a/linux/packaging/deb/make_config.yaml b/linux/packaging/deb/make_config.yaml index 46493122d..f4c279b49 100644 --- a/linux/packaging/deb/make_config.yaml +++ b/linux/packaging/deb/make_config.yaml @@ -32,3 +32,6 @@ keywords: generic_name: Music Streaming Application categories: - Music + +supported_mime_type: + - x-scheme-handler/spotify diff --git a/linux/packaging/rpm/make_config.yaml b/linux/packaging/rpm/make_config.yaml index 00f4c20ef..1f952d0e5 100644 --- a/linux/packaging/rpm/make_config.yaml +++ b/linux/packaging/rpm/make_config.yaml @@ -28,3 +28,6 @@ categories: - Music startup_notify: true + +supported_mime_type: + - x-scheme-handler/spotify diff --git a/macos/Flutter/GeneratedPluginRegistrant.swift b/macos/Flutter/GeneratedPluginRegistrant.swift index 270e62614..a7965e14c 100644 --- a/macos/Flutter/GeneratedPluginRegistrant.swift +++ b/macos/Flutter/GeneratedPluginRegistrant.swift @@ -5,6 +5,7 @@ import FlutterMacOS import Foundation +import app_links import audio_service import audio_session import device_info_plus @@ -24,6 +25,7 @@ import window_manager import window_size func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) { + AppLinksMacosPlugin.register(with: registry.registrar(forPlugin: "AppLinksMacosPlugin")) AudioServicePlugin.register(with: registry.registrar(forPlugin: "AudioServicePlugin")) AudioSessionPlugin.register(with: registry.registrar(forPlugin: "AudioSessionPlugin")) DeviceInfoPlusMacosPlugin.register(with: registry.registrar(forPlugin: "DeviceInfoPlusMacosPlugin")) diff --git a/macos/Runner/Info.plist b/macos/Runner/Info.plist index 19f1c02a2..1a8bb655f 100644 --- a/macos/Runner/Info.plist +++ b/macos/Runner/Info.plist @@ -2,6 +2,19 @@ + CFBundleURLTypes + + + CFBundleURLName + + Spotify + CFBundleURLSchemes + + + spotify + + + CFBundleDevelopmentRegion $(DEVELOPMENT_LANGUAGE) CFBundleExecutable diff --git a/pubspec.lock b/pubspec.lock index 8921f8a7d..526898d50 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -17,6 +17,14 @@ packages: url: "https://pub.dev" source: hosted version: "5.13.0" + app_links: + dependency: "direct main" + description: + name: app_links + sha256: "4e392b5eba997df356ca6021f28431ce1cfeb16758699553a94b13add874a3bb" + url: "https://pub.dev" + source: hosted + version: "3.5.0" app_package_maker: dependency: transitive description: @@ -907,6 +915,14 @@ packages: url: "https://pub.dev" source: hosted version: "3.0.0" + flutter_sharing_intent: + dependency: "direct main" + description: + name: flutter_sharing_intent + sha256: "6eb896e6523b735e8230eeb206fd3b9f220f11ce879c2400a90b443147036ff9" + url: "https://pub.dev" + source: hosted + version: "1.1.0" flutter_svg: dependency: "direct main" description: @@ -1018,6 +1034,14 @@ packages: url: "https://pub.dev" source: hosted version: "0.2.8" + gtk: + dependency: transitive + description: + name: gtk + sha256: e8ce9ca4b1df106e4d72dad201d345ea1a036cc12c360f1a7d5a758f78ffa42c + url: "https://pub.dev" + source: hosted + version: "2.1.0" hive: dependency: "direct main" description: @@ -2246,13 +2270,13 @@ packages: source: hosted version: "5.0.7" win32_registry: - dependency: transitive + dependency: "direct main" description: name: win32_registry - sha256: e4506d60b7244251bc59df15656a3093501c37fb5af02105a944d73eb95be4c9 + sha256: "41fd8a189940d8696b1b810efb9abcf60827b6cbfab90b0c43e8439e3a39d85a" url: "https://pub.dev" source: hosted - version: "1.1.1" + version: "1.1.2" window_manager: dependency: "direct main" description: diff --git a/pubspec.yaml b/pubspec.yaml index 77a26911c..267ab17fe 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -119,6 +119,9 @@ dependencies: html_unescape: ^2.0.0 wikipedia_api: ^0.1.0 skeletonizer: ^0.8.0 + app_links: ^3.5.0 + win32_registry: ^1.1.2 + flutter_sharing_intent: ^1.1.0 dev_dependencies: build_runner: ^2.3.2 diff --git a/untranslated_messages.json b/untranslated_messages.json index e3bdc0478..438ad6b35 100644 --- a/untranslated_messages.json +++ b/untranslated_messages.json @@ -39,6 +39,12 @@ "discord_rich_presence" ], + "it": [ + "audio_source", + "go_to_album", + "discord_rich_presence" + ], + "ja": [ "go_to_album", "discord_rich_presence" diff --git a/windows/flutter/generated_plugin_registrant.cc b/windows/flutter/generated_plugin_registrant.cc index b9c6a4816..fcf9927e0 100644 --- a/windows/flutter/generated_plugin_registrant.cc +++ b/windows/flutter/generated_plugin_registrant.cc @@ -6,6 +6,7 @@ #include "generated_plugin_registrant.h" +#include #include #include #include @@ -20,6 +21,8 @@ #include void RegisterPlugins(flutter::PluginRegistry* registry) { + AppLinksPluginCApiRegisterWithRegistrar( + registry->GetRegistrarForPlugin("AppLinksPluginCApi")); DartDiscordRpcPluginRegisterWithRegistrar( registry->GetRegistrarForPlugin("DartDiscordRpcPlugin")); FileSelectorWindowsRegisterWithRegistrar( diff --git a/windows/flutter/generated_plugins.cmake b/windows/flutter/generated_plugins.cmake index 5cd55ff37..0fe6e076b 100644 --- a/windows/flutter/generated_plugins.cmake +++ b/windows/flutter/generated_plugins.cmake @@ -3,6 +3,7 @@ # list(APPEND FLUTTER_PLUGIN_LIST + app_links dart_discord_rpc file_selector_windows flutter_secure_storage_windows diff --git a/windows/runner/win32_window.cpp b/windows/runner/win32_window.cpp index d5c04f23e..9823151cd 100644 --- a/windows/runner/win32_window.cpp +++ b/windows/runner/win32_window.cpp @@ -3,6 +3,7 @@ #include #include "resource.h" +#include "app_links/app_links_plugin_c_api.h" namespace { @@ -105,6 +106,9 @@ Win32Window::~Win32Window() { bool Win32Window::CreateAndShow(const std::wstring& title, const Point& origin, const Size& size) { + if (SendAppLinkToInstance(title)) { + return false; + } Destroy(); const wchar_t* window_class = @@ -244,3 +248,39 @@ bool Win32Window::OnCreate() { void Win32Window::OnDestroy() { // No-op; provided for subclasses. } + +// app_links +bool Win32Window::SendAppLinkToInstance(const std::wstring& title) { + // Find our exact window + HWND hwnd = ::FindWindow(kWindowClassName, title.c_str()); + + if (hwnd) { + // Dispatch new link to current window + SendAppLink(hwnd); + + // (Optional) Restore our window to front in same state + WINDOWPLACEMENT place = { sizeof(WINDOWPLACEMENT) }; + GetWindowPlacement(hwnd, &place); + + switch(place.showCmd) { + case SW_SHOWMAXIMIZED: + ShowWindow(hwnd, SW_SHOWMAXIMIZED); + break; + case SW_SHOWMINIMIZED: + ShowWindow(hwnd, SW_RESTORE); + break; + default: + ShowWindow(hwnd, SW_NORMAL); + break; + } + + SetWindowPos(0, HWND_TOP, 0, 0, 0, 0, SWP_SHOWWINDOW | SWP_NOSIZE | SWP_NOMOVE); + SetForegroundWindow(hwnd); + // END Restore + + // Window has been found, don't create another one. + return true; + } + + return false; +} \ No newline at end of file diff --git a/windows/runner/win32_window.h b/windows/runner/win32_window.h index 17ba43112..1d817bd2f 100644 --- a/windows/runner/win32_window.h +++ b/windows/runner/win32_window.h @@ -93,6 +93,10 @@ class Win32Window { // window handle for hosted content. HWND child_content_ = nullptr; + // Dispatches link if any. + // This method enables our app to be with a single instance too. + // This is mandatory if you want to catch further links in same app. + bool SendAppLinkToInstance(const std::wstring& title); }; #endif // RUNNER_WIN32_WINDOW_H_