Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion android/build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ def safeEnvFlagGet(prop) {
return normalizedValue != null && (normalizedValue.equalsIgnoreCase('true') || normalizedValue == '1')
}

def oneSignalVersion = '5.10.2'
def oneSignalVersion = '5.11.0'
def oneSignalDisableLocation = safeEnvFlagGet('ONESIGNAL_DISABLE_LOCATION')

buildscript {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@
import android.content.Context;
import androidx.annotation.NonNull;
import com.onesignal.OneSignal;
import com.onesignal.OneSignalResult;
import com.onesignal.OneSignalUserProfile;
import com.onesignal.common.OneSignalWrapper;
import io.flutter.embedding.engine.plugins.FlutterPlugin;
import io.flutter.embedding.engine.plugins.activity.ActivityAware;
Expand All @@ -12,6 +14,13 @@
import io.flutter.plugin.common.MethodChannel;
import io.flutter.plugin.common.MethodChannel.MethodCallHandler;
import io.flutter.plugin.common.MethodChannel.Result;
import java.util.HashMap;
import java.util.Map;
import java.util.concurrent.atomic.AtomicBoolean;
import kotlin.coroutines.Continuation;
import kotlin.coroutines.CoroutineContext;
import kotlin.coroutines.EmptyCoroutineContext;
import kotlin.coroutines.intrinsics.IntrinsicsKt;

/** OnesignalPlugin */
public class OneSignalPlugin extends FlutterMessengerResponder
Expand Down Expand Up @@ -101,6 +110,7 @@ private void handleMethodCall(MethodCall call, Result result) {
else if (call.method.contentEquals("OneSignal#consentGiven")) this.setConsentGiven(call, result);
else if (call.method.contentEquals("OneSignal#login")) this.login(call, result);
else if (call.method.contentEquals("OneSignal#loginWithJWT")) this.loginWithJWT(call, result);
else if (call.method.contentEquals("OneSignal#loginWithProfile")) this.loginWithProfile(call, result);
else if (call.method.contentEquals("OneSignal#logout")) this.logout(call, result);
else replyNotImplemented(result);
}
Expand Down Expand Up @@ -133,6 +143,73 @@ private void loginWithJWT(MethodCall call, Result result) {
replySuccess(result, null);
}

private void loginWithProfile(MethodCall call, final Result result) {
Map<String, Object> rawProfile = call.argument("profile");
OneSignalUserProfile profile = new OneSignalUserProfile.Builder()
.setEmail(stringOrNull(rawProfile, "email"))
.setSms(stringOrNull(rawProfile, "sms"))
.setTags(stringMap(rawProfile, "tags"))
.setAliases(stringMap(rawProfile, "aliases"))
.build();

final AtomicBoolean replied = new AtomicBoolean(false);
Continuation<Object> continuation = new Continuation<Object>() {
@NonNull
@Override
public CoroutineContext getContext() {
return EmptyCoroutineContext.INSTANCE;
}

@Override
public void resumeWith(@NonNull Object outcome) {
replyLoginResult(result, replied, outcome);
}
};

Object returned = OneSignal.login(
(String) call.argument("externalId"), profile, (String) call.argument("jwtBearerToken"), continuation);
// A suspend function that completes without suspending returns its result here
// and never calls resumeWith.
if (returned != IntrinsicsKt.getCOROUTINE_SUSPENDED()) {
replyLoginResult(result, replied, returned);
}
}

private void replyLoginResult(Result result, AtomicBoolean replied, Object outcome) {
if (!replied.compareAndSet(false, true)) return;
String message = "Unexpected loginWithProfile result";
if (outcome instanceof OneSignalResult) {
// May run on the SDK's coroutine thread, so a conversion failure must not escape.
try {
replySuccess(result, OneSignalSerializer.convertToCodecValue(((OneSignalResult<?>) outcome).toMap()));
return;
} catch (Throwable t) {
message = String.valueOf(t);
}
} else if (outcome instanceof kotlin.Result.Failure) {
Throwable exception = ((kotlin.Result.Failure) outcome).exception;
message = exception.getMessage() != null ? exception.getMessage() : exception.toString();
}
replyError(result, "OneSignal", message, null);
}

private static String stringOrNull(Map<String, Object> map, String key) {
Object value = map == null ? null : map.get(key);
return value instanceof String ? (String) value : null;
}

private static Map<String, String> stringMap(Map<String, Object> map, String key) {
Map<String, String> converted = new HashMap<>();
Object value = map == null ? null : map.get(key);
if (!(value instanceof Map)) return converted;
for (Map.Entry<?, ?> entry : ((Map<?, ?>) value).entrySet()) {
if (entry.getKey() != null && entry.getValue() != null) {
converted.put(entry.getKey().toString(), entry.getValue().toString());
}
}
return converted;
}

private void logout(MethodCall call, Result result) {
OneSignal.logout();
replySuccess(result, null);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,9 +17,11 @@
import com.onesignal.user.subscriptions.PushSubscriptionChangedState;
import com.onesignal.user.subscriptions.PushSubscriptionState;
import java.util.ArrayList;
import java.util.Collection;
import java.util.HashMap;
import java.util.Iterator;
import java.util.List;
import java.util.Map;
import org.json.JSONArray;
import org.json.JSONException;
import org.json.JSONObject;
Expand Down Expand Up @@ -255,6 +257,33 @@ private static List<Object> convertJSONArrayToList(JSONArray array) throws JSONE
return list;
}

/** Reduces a value to types StandardMessageCodec can encode, since an unsupported type throws on the main thread. */
static Object convertToCodecValue(Object value) {
if (value == null || value == JSONObject.NULL) return null;
if (value instanceof String || value instanceof Boolean) return value;
if (value instanceof Integer || value instanceof Long || value instanceof Double) return value;
if (value instanceof Number) return ((Number) value).doubleValue();
if (value instanceof Map) {
HashMap<String, Object> map = new HashMap<>();
for (Map.Entry<?, ?> entry : ((Map<?, ?>) value).entrySet()) {
map.put(String.valueOf(entry.getKey()), convertToCodecValue(entry.getValue()));
}
return map;
}
if (value instanceof Collection) {
List<Object> list = new ArrayList<>();
for (Object item : (Collection<?>) value) list.add(convertToCodecValue(item));
return list;
}
try {
if (value instanceof JSONObject) return convertJSONObjectToHashMap((JSONObject) value);
if (value instanceof JSONArray) return convertJSONArrayToList((JSONArray) value);
} catch (JSONException e) {
return value.toString();
}
return value.toString();
}

/** Helper method to return null value if string is empty **/
static String setNullIfEmpty(String value) {
return value.isEmpty() ? null : value;
Expand Down
34 changes: 34 additions & 0 deletions examples/demo-fm/lib/viewmodels/app_viewmodel.dart
Original file line number Diff line number Diff line change
Expand Up @@ -260,6 +260,40 @@ class AppViewModel extends ChangeNotifier {
}
}

Future<void> loginWithProfile(
String externalUserId, {
String? email,
String? sms,
String? jwt,
}) async {
_aliasesList = [];
_emailsList = [];
_smsNumbersList = [];
_tagsList = [];
_triggersList = [];
_isLoading = true;
notifyListeners();

final result = await OneSignal.loginWithProfile(
externalUserId,
OSUserProfile(email: email, sms: sms),
jwt,
);
if (!result.success) {
final error = result.error?.first;
debugPrint('Login with profile error: ${error?.code} ${error?.message}');
_isLoading = false;
notifyListeners();
return;
}

_externalUserId = externalUserId;
await _prefs.setExternalUserId(externalUserId);
debugPrint('Logged in with profile as: $externalUserId');
// The user observer does not fire when the external id is unchanged.
await fetchUserDataFromApi();
}

Future<void> logoutUser() async {
_externalUserId = null;
_aliasesList = [];
Expand Down
119 changes: 119 additions & 0 deletions examples/demo-fm/lib/widgets/dialogs.dart
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,125 @@ class _SingleInputDialogState extends State<SingleInputDialog> {
}
}

typedef LoginWithProfileInput = ({
String externalId,
String? email,
String? sms,
String? jwt,
});

// Login with profile dialog (external id plus optional email, sms, jwt)
class LoginWithProfileDialog extends StatefulWidget {
const LoginWithProfileDialog({super.key});

@override
State<LoginWithProfileDialog> createState() => _LoginWithProfileDialogState();
}

class _LoginWithProfileDialogState extends State<LoginWithProfileDialog> {
final _externalIdController = TextEditingController();
final _emailController = TextEditingController();
final _smsController = TextEditingController();
final _jwtController = TextEditingController();

@override
void dispose() {
_externalIdController.dispose();
_emailController.dispose();
_smsController.dispose();
_jwtController.dispose();
super.dispose();
}

String? _optional(TextEditingController controller) {
final text = controller.text.trim();
return text.isEmpty ? null : text;
}

Widget _field(
String identifier,
String label,
TextEditingController controller, {
TextInputType keyboardType = TextInputType.text,
}) {
return Semantics(
identifier: identifier,
container: true,
child: AppTextField(
controller: controller,
decoration: InputDecoration(labelText: label),
keyboardType: keyboardType,
onChanged: (_) => setState(() {}),
),
);
}

@override
Widget build(BuildContext context) {
return AlertDialog(
insetPadding: const EdgeInsets.symmetric(horizontal: 16),
title: const Text('Login With Profile'),
content: SizedBox(
width: double.maxFinite,
child: SingleChildScrollView(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
_field(
'composite_login_user_id_input',
'External User Id',
_externalIdController,
),
AppSpacing.gapBox,
_field(
'composite_login_email_input',
'Email (optional)',
_emailController,
keyboardType: TextInputType.emailAddress,
),
AppSpacing.gapBox,
_field(
'composite_login_sms_input',
'SMS (optional)',
_smsController,
keyboardType: TextInputType.phone,
),
AppSpacing.gapBox,
_field(
'composite_login_jwt_input',
'JWT Token (optional)',
_jwtController,
),
],
),
),
),
actions: [
TextButton(
onPressed: () => Navigator.pop(context),
child: const Text('Cancel'),
),
Semantics(
identifier: 'composite_login_confirm_button',
container: true,
button: true,
child: TextButton(
onPressed: _externalIdController.text.trim().isEmpty
? null
: () => Navigator.pop<LoginWithProfileInput>(context, (
externalId: _externalIdController.text.trim(),
email: _optional(_emailController),
sms: _optional(_smsController),
jwt: _optional(_jwtController),
)),
child: const Text('Login'),
),
),
],
);
}
}

// Key-value pair input dialog (single pair)
class PairInputDialog extends StatefulWidget {
final String title;
Expand Down
22 changes: 22 additions & 0 deletions examples/demo-fm/lib/widgets/sections/user_section.dart
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';

Expand Down Expand Up @@ -91,6 +92,27 @@ class UserSection extends StatelessWidget {
}
},
),
if (defaultTargetPlatform == TargetPlatform.android) ...[
AppSpacing.gapBox,
DestructiveButton(
label: 'LOGIN WITH PROFILE',
semanticsLabel: 'composite_login_button',
onPressed: () async {
final input = await showDialog<LoginWithProfileInput>(
context: context,
builder: (_) => const LoginWithProfileDialog(),
);
if (input != null && context.mounted) {
await vm.loginWithProfile(
input.externalId,
email: input.email,
sms: input.sms,
jwt: input.jwt,
);
}
},
),
],
if (vm.isLoggedIn) ...[
AppSpacing.gapBox,
DestructiveButton(
Expand Down
Loading
Loading