Skip to content

Commit

Permalink
Ability to share links/media to Thunder on Android (#843)
Browse files Browse the repository at this point in the history
* init attempt receive share intents android

* support for receiving share intents, links as text, text, image files

* Rm unused url from _navigateToCreatePostPage

* update changelog :receiving share intents (android)

* lable in share sheet,
deep links chrome override behavior docs, if text is url in receive intents,

* support for receiving share intents, links as text, text, image files

* grab link title from post url.
  • Loading branch information
ggichure authored Oct 25, 2023
1 parent 12797d6 commit 7dc0232
Show file tree
Hide file tree
Showing 11 changed files with 275 additions and 54 deletions.
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
- Added option to enter reader mode when tapping on a link in iOS
- Support new scaled and controversial sort types - contribution from @micahmo
- Added support to open Lemmy links in app. Android only. - contribution from @ggichure
- Added support for receiving share intents. Android only. - contribution from @ggichure

### Changed
- Collapsed comments are easier to expand - contribution from @micahmo
Expand Down
18 changes: 17 additions & 1 deletion android/app/src/main/AndroidManifest.xml
Original file line number Diff line number Diff line change
Expand Up @@ -16,11 +16,27 @@
<activity
android:name=".MainActivity"
android:exported="true"
android:launchMode="singleTop"
android:launchMode="singleTask"
android:theme="@style/LaunchTheme"
android:configChanges="orientation|keyboardHidden|keyboard|screenSize|smallestScreenSize|locale|layoutDirection|fontScale|screenLayout|density|uiMode"
android:hardwareAccelerated="true"
android:windowSoftInputMode="adjustResize">
<!-- support sharing images -->
<intent-filter
android:label="@string/create_post">
<action android:name="android.intent.action.SEND" />
<category android:name="android.intent.category.DEFAULT" />
<data android:mimeType="image/*" />
</intent-filter>
<!--support sharing text -->
<intent-filter
android:label="@string/create_post">
<action android:name="android.intent.action.SEND" />
<category android:name="android.intent.category.DEFAULT" />
<data android:mimeType="text/plain" />
</intent-filter>


<!-- Specifies an Android theme to apply to this Activity as soon as
the Android process has started. This theme is visible to the user
while the Flutter UI initializes. After that, this theme continues
Expand Down
33 changes: 32 additions & 1 deletion android/app/src/main/kotlin/com/example/thunder/MainActivity.kt
Original file line number Diff line number Diff line change
@@ -1,6 +1,37 @@
package com.hjiangsu.thunder

import android.content.Intent
import androidx.annotation.NonNull;
import io.flutter.embedding.android.FlutterActivity
import io.flutter.embedding.engine.FlutterEngine
import io.flutter.plugins.GeneratedPluginRegistrant
import android.os.Bundle

class MainActivity: FlutterActivity() {
}
override fun configureFlutterEngine(@NonNull flutterEngine: FlutterEngine) {
GeneratedPluginRegistrant.registerWith(flutterEngine);
java.lang.Thread.sleep(1000);
}
/**
* When triggering an implicit deep link, the state of the back stack depends on whether the implicit Intent was launched with the Intent.FLAG_ACTIVITY_NEW_TASK flag:
*
* - If the flag is set, the task back stack is cleared and replaced with the deep link destination. As with explicit deep linking,
* when nesting graphs, the start destination from each level of nesting—that is, the start destination from each element in the
* hierarchy—is also added to the stack. This means that when a user presses the Back button from a deep link destination, they
* navigate back up the navigation stack just as though they entered your app from its entry point.
* - If the flag is not set, you remain on the task stack of the previous app where the implicit deep link was triggered. In this case,
* the Back button takes you back to the previous app, while the Up button starts your app's task on the hierarchical parent destination within your navigation graph.
*
* TLDR: The app that launches your app can change this behavior.
*
* Source: https://developer.android.com/guide/navigation/navigation-deep-link#implicit
*/
override fun onCreate(savedInstanceState: Bundle?) {
if (intent.getIntExtra("org.chromium.chrome.extra.TASK_ID", -1) == this.taskId) {
this.finish()
intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
startActivity(intent);
}
super.onCreate(savedInstanceState)
}
}
4 changes: 4 additions & 0 deletions android/app/src/main/res/values/strings.xml
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="create_post">Create Post</string>
</resources>
39 changes: 37 additions & 2 deletions lib/community/pages/create_post_page.dart
Original file line number Diff line number Diff line change
@@ -1,7 +1,10 @@
import 'dart:io';

import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';

import 'package:lemmy_api_client/v3.dart';
import 'package:link_preview_generator/link_preview_generator.dart';
import 'package:markdown_editable_textinput/format_markdown.dart';
import 'package:markdown_editable_textinput/markdown_buttons.dart';
import 'package:markdown_editable_textinput/markdown_text_input_field.dart';
Expand All @@ -27,12 +30,23 @@ class CreatePostPage extends StatefulWidget {
final void Function(DraftPost? draftPost)? onUpdateDraft;
final DraftPost? previousDraftPost;

// used create post from action sheet
final String? text;
final File? image;
final String? url;

final bool? prePopulated;

const CreatePostPage({
super.key,
required this.communityId,
this.communityView,
this.previousDraftPost,
this.onUpdateDraft,
this.image,
this.text,
this.url,
this.prePopulated = false,
});

@override
Expand Down Expand Up @@ -84,14 +98,28 @@ class _CreatePostPageState extends State<CreatePostPage> {
widget.onUpdateDraft?.call(newDraftPost..text = _bodyTextController.text);
});

if (widget.previousDraftPost != null) {
if (widget.prePopulated == true) {
_bodyTextController.text = widget.text ?? '';
_urlTextController.text = widget.url ?? '';
_getDataFromLink();
if (widget.image != null) {
WidgetsBinding.instance.addPostFrameCallback((timeStamp) {
uploadImage(
context,
imageBloc,
postImage: true,
imagePath: widget.image?.path,
);
});
}
} else if (widget.previousDraftPost != null) {
_titleTextController.text = widget.previousDraftPost!.title ?? '';
_urlTextController.text = widget.previousDraftPost!.url ?? '';
_bodyTextController.text = widget.previousDraftPost!.text ?? '';

WidgetsBinding.instance.addPostFrameCallback((timeStamp) async {
await Future.delayed(const Duration(milliseconds: 300));
showSnackbar(context, AppLocalizations.of(context)!.restoredPostFromDraft);
if (context.mounted) showSnackbar(context, AppLocalizations.of(context)!.restoredPostFromDraft);
});
}
}
Expand All @@ -108,6 +136,13 @@ class _CreatePostPageState extends State<CreatePostPage> {
super.dispose();
}

Future<void> _getDataFromLink() async {
if (widget.url?.isNotEmpty == true) {
final WebInfo info = await LinkPreview.scrapeFromURL(widget.url!);
_titleTextController.text = info.title;
}
}

@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
Expand Down
65 changes: 65 additions & 0 deletions lib/thunder/pages/thunder_page.dart
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import 'dart:async';
import 'dart:io';

// Flutter
import 'package:flutter/material.dart';
Expand All @@ -9,11 +10,14 @@ import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:flutter_native_splash/flutter_native_splash.dart';
import 'package:lemmy_api_client/v3.dart';
import 'package:overlay_support/overlay_support.dart';

import 'package:shared_preferences/shared_preferences.dart';
import 'package:thunder/account/models/account.dart';

import 'package:thunder/account/utils/profiles.dart';
import 'package:thunder/community/bloc/community_bloc.dart';
import 'package:thunder/community/widgets/community_drawer.dart';
import 'package:receive_sharing_intent/receive_sharing_intent.dart';
import 'package:thunder/core/auth/helpers/fetch_account.dart';
import 'package:collection/collection.dart';

Expand Down Expand Up @@ -43,6 +47,7 @@ import 'package:thunder/settings/pages/settings_page.dart';
import 'package:thunder/shared/error_message.dart';
import 'package:thunder/thunder/bloc/thunder_bloc.dart';
import 'package:thunder/utils/navigate_comment.dart';
import 'package:thunder/utils/navigate_create_post.dart';
import 'package:thunder/utils/navigate_instance.dart';
import 'package:thunder/utils/navigate_post.dart';
import 'package:thunder/utils/navigate_user.dart';
Expand All @@ -65,11 +70,18 @@ class _ThunderState extends State<Thunder> {
bool _isFabOpen = false;

bool reduceAnimations = false;

final GlobalKey<ScaffoldMessengerState> scaffoldMessengerKey = GlobalKey<ScaffoldMessengerState>();

late final StreamSubscription mediaIntentDataStreamSubscription;

late final StreamSubscription textIntentDataStreamSubscription;

@override
void initState() {
super.initState();
WidgetsBinding.instance.addPostFrameCallback((timeStamp) {
handleSharedFilesAndText();
BlocProvider.of<DeepLinksCubit>(context).handleIncomingLinks();
BlocProvider.of<DeepLinksCubit>(context).handleInitialURI();
});
Expand All @@ -78,9 +90,62 @@ class _ThunderState extends State<Thunder> {
@override
void dispose() {
pageController.dispose();
textIntentDataStreamSubscription.cancel();
mediaIntentDataStreamSubscription.cancel();
super.dispose();
}

// All listeners to listen Sharing media files & text
void handleSharedFilesAndText() {
try {
handleSharedImages();
handleSharedText();
} catch (e) {
if (context.mounted) showSnackbar(context, AppLocalizations.of(context)!.unexpectedError);
}
}

void handleSharedImages() async {
// For sharing images from outside the app while the app is closed
final initialMedia = await ReceiveSharingIntent.getInitialMedia();
if (initialMedia.isNotEmpty) {
if (context.mounted) navigateToCreatePostPage(context, image: File(initialMedia.first.path), prePopulated: true);
}
// For sharing images while the app is in the memory
mediaIntentDataStreamSubscription = ReceiveSharingIntent.getMediaStream().listen((
List<SharedMediaFile> value,
) {
if (context.mounted) navigateToCreatePostPage(context, image: File(value.first.path), prePopulated: true);
});
}

void handleSharedText() async {
// For sharing URLs/text from outside the app while the app is closed
final initialText = await ReceiveSharingIntent.getInitialText();
if (initialText?.isNotEmpty ?? false) {
final uri = Uri.tryParse(initialText!);
if (uri?.isAbsolute == true) {
if (context.mounted) navigateToCreatePostPage(context, url: uri.toString(), prePopulated: true);
} else {
if (context.mounted) navigateToCreatePostPage(context, text: initialText, prePopulated: true);
}
}

// For sharing URLs/text while the app is in the memory
textIntentDataStreamSubscription = ReceiveSharingIntent.getTextStream().listen((
String? value,
) {
if (value?.isNotEmpty ?? false) {
final uri = Uri.tryParse(value!);
if (uri?.isAbsolute == true) {
if (context.mounted) navigateToCreatePostPage(context, url: uri.toString(), prePopulated: true);
} else {
if (context.mounted) navigateToCreatePostPage(context, text: value, prePopulated: true);
}
}
});
}

void _showExitWarning() {
showSnackbar(context, AppLocalizations.of(context)!.tapToExit, duration: const Duration(milliseconds: 3500));
}
Expand Down
12 changes: 9 additions & 3 deletions lib/utils/image.dart
Original file line number Diff line number Diff line change
Expand Up @@ -134,12 +134,18 @@ Size getWEBPImageDimensions(Uint8List bytes) {
return Size(width.toDouble(), height.toDouble());
}

void uploadImage(BuildContext context, ImageBloc imageBloc, {bool postImage = false}) async {
void uploadImage(BuildContext context, ImageBloc imageBloc, {bool postImage = false, String? imagePath}) async {
final ImagePicker picker = ImagePicker();
XFile? file = await picker.pickImage(source: ImageSource.gallery);
String path;
if (imagePath?.isEmpty ?? false) {
XFile? file = await picker.pickImage(source: ImageSource.gallery);
path = file!.path;
} else {
path = imagePath!;
}

try {
Account? account = await fetchActiveProfileAccount();
String path = file!.path;
imageBloc.add(ImageUploadEvent(imageFile: path, instance: account!.instance!, jwt: account.jwt!, postImage: postImage));
} catch (e) {
showSnackbar(context, AppLocalizations.of(context)!.postUploadImageError, leadingIcon: Icons.warning_rounded, leadingIconColor: Theme.of(context).colorScheme.errorContainer);
Expand Down
50 changes: 50 additions & 0 deletions lib/utils/navigate_create_post.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
import 'dart:io';

import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:swipeable_page_route/swipeable_page_route.dart';
import 'package:thunder/account/bloc/account_bloc.dart';
import 'package:thunder/community/pages/create_post_page.dart';
import 'package:thunder/core/singletons/lemmy_client.dart';
import 'package:thunder/feed/feed.dart';
import 'package:thunder/shared/snackbar.dart';
import 'package:thunder/thunder/thunder.dart';
import 'package:flutter_gen/gen_l10n/app_localizations.dart';

Future<void> navigateToCreatePostPage(
BuildContext context, {
String? text,
File? image,
String? url,
bool? prePopulated,
}) async {
try {
ThunderBloc thunderBloc = context.read<ThunderBloc>();
AccountBloc accountBloc = context.read<AccountBloc>();
final bool reduceAnimations = thunderBloc.state.reduceAnimations;
Navigator.of(context).push(SwipeablePageRoute(
transitionDuration: reduceAnimations ? const Duration(milliseconds: 100) : null,
canOnlySwipeFromEdge: true,
backGestureDetectionWidth: 45,
builder: (context) {
return MultiBlocProvider(
providers: [
BlocProvider(create: (context) => FeedBloc(lemmyClient: LemmyClient.instance)),
BlocProvider<ThunderBloc>.value(value: thunderBloc),
BlocProvider<AccountBloc>.value(value: accountBloc),
],
child: CreatePostPage(
text: text,
image: image,
url: url,
prePopulated: prePopulated,
onUpdateDraft: (p) => {},
communityId: null,
),
);
},
));
} catch (e) {
if (context.mounted) showSnackbar(context, AppLocalizations.of(context)!.unexpectedError);
}
}
2 changes: 1 addition & 1 deletion macos/Flutter/GeneratedPluginRegistrant.swift
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) {
DeviceInfoPlusMacosPlugin.register(with: registry.registrar(forPlugin: "DeviceInfoPlusMacosPlugin"))
DynamicColorPlugin.register(with: registry.registrar(forPlugin: "DynamicColorPlugin"))
FileSelectorPlugin.register(with: registry.registrar(forPlugin: "FileSelectorPlugin"))
FLTPackageInfoPlusPlugin.register(with: registry.registrar(forPlugin: "FLTPackageInfoPlusPlugin"))
FPPPackageInfoPlusPlugin.register(with: registry.registrar(forPlugin: "FPPPackageInfoPlusPlugin"))
PathProviderPlugin.register(with: registry.registrar(forPlugin: "PathProviderPlugin"))
SharePlusMacosPlugin.register(with: registry.registrar(forPlugin: "SharePlusMacosPlugin"))
SharedPreferencesPlugin.register(with: registry.registrar(forPlugin: "SharedPreferencesPlugin"))
Expand Down
Loading

0 comments on commit 7dc0232

Please sign in to comment.