diff --git a/Android/samples/graphnotificationssample/.gitignore b/Android/samples/graphnotificationssample/.gitignore new file mode 100644 index 0000000..39fb081 --- /dev/null +++ b/Android/samples/graphnotificationssample/.gitignore @@ -0,0 +1,9 @@ +*.iml +.gradle +/local.properties +/.idea/workspace.xml +/.idea/libraries +.DS_Store +/build +/captures +.externalNativeBuild diff --git a/Android/samples/graphnotificationssample/app/.gitignore b/Android/samples/graphnotificationssample/app/.gitignore new file mode 100644 index 0000000..796b96d --- /dev/null +++ b/Android/samples/graphnotificationssample/app/.gitignore @@ -0,0 +1 @@ +/build diff --git a/Android/samples/graphnotificationssample/app/build.gradle b/Android/samples/graphnotificationssample/app/build.gradle new file mode 100644 index 0000000..d70e59c --- /dev/null +++ b/Android/samples/graphnotificationssample/app/build.gradle @@ -0,0 +1,48 @@ +apply plugin: 'com.android.application' + +android { + compileSdkVersion 27 + defaultConfig { + applicationId "com.microsoft.connecteddevices.graphnotifications" + minSdkVersion 19 + targetSdkVersion 27 + versionCode 1 + versionName "1.0" + testInstrumentationRunner "android.support.test.runner.AndroidJUnitRunner" + } + buildTypes { + release { + minifyEnabled false + proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.pro' + } + } + + packagingOptions { + pickFirst 'lib/x86/libc++_shared.so' + pickFirst 'lib/armeabi-v7a/libc++_shared.so' + pickFirst 'lib/arm64-v8a/libc++_shared.so' + pickFirst 'lib/x86_64/libc++_shared.so' + } +} + +dependencies { + implementation fileTree(dir: 'libs', include: ['*.jar']) + implementation 'com.android.support:appcompat-v7:27.1.1' + implementation 'com.android.support:design:27.1.1' + implementation 'com.android.support.constraint:constraint-layout:1.1.2' + implementation 'com.google.firebase:firebase-messaging:17.1.0' + implementation 'com.google.firebase:firebase-core:16.0.1' + + testImplementation 'junit:junit:4.12' + androidTestImplementation 'com.android.support.test:runner:1.0.2' + androidTestImplementation 'com.android.support.test.espresso:espresso-core:3.0.2' + + // Local version of SDK, since we don't have public maven (yet) + implementation(name: 'connecteddevices-sdk', ext: 'aar') + + // For login + implementation project(path: ':sampleaccountproviders') +} + + +apply plugin: 'com.google.gms.google-services' diff --git a/Android/samples/graphnotificationssample/app/google-services.json b/Android/samples/graphnotificationssample/app/google-services.json new file mode 100644 index 0000000..61b144c --- /dev/null +++ b/Android/samples/graphnotificationssample/app/google-services.json @@ -0,0 +1,42 @@ +{ + "project_info": { + "project_number": "782586391244", + "firebase_url": "https://graphnotifications-a61a7.firebaseio.com", + "project_id": "graphnotifications-a61a7", + "storage_bucket": "graphnotifications-a61a7.appspot.com" + }, + "client": [ + { + "client_info": { + "mobilesdk_app_id": "1:782586391244:android:7bd1ae34fbb0ccaa", + "android_client_info": { + "package_name": "com.microsoft.connecteddevices.graphnotifications" + } + }, + "oauth_client": [ + { + "client_id": "782586391244-93dahs8garncusah95oene5gk1sj88uh.apps.googleusercontent.com", + "client_type": 3 + } + ], + "api_key": [ + { + "current_key": "AIzaSyCsYVrZK8DVYTTIP_YdT-5NCuRY5sIkmqM" + } + ], + "services": { + "analytics_service": { + "status": 1 + }, + "appinvite_service": { + "status": 1, + "other_platform_oauth_client": [] + }, + "ads_service": { + "status": 2 + } + } + } + ], + "configuration_version": "1" +} \ No newline at end of file diff --git a/Android/samples/graphnotificationssample/app/proguard-rules.pro b/Android/samples/graphnotificationssample/app/proguard-rules.pro new file mode 100644 index 0000000..f1b4245 --- /dev/null +++ b/Android/samples/graphnotificationssample/app/proguard-rules.pro @@ -0,0 +1,21 @@ +# Add project specific ProGuard rules here. +# You can control the set of applied configuration files using the +# proguardFiles setting in build.gradle. +# +# For more details, see +# http://developer.android.com/guide/developing/tools/proguard.html + +# If your project uses WebView with JS, uncomment the following +# and specify the fully qualified class name to the JavaScript interface +# class: +#-keepclassmembers class fqcn.of.javascript.interface.for.webview { +# public *; +#} + +# Uncomment this to preserve the line number information for +# debugging stack traces. +#-keepattributes SourceFile,LineNumberTable + +# If you keep the line number information, uncomment this to +# hide the original source file name. +#-renamesourcefileattribute SourceFile diff --git a/Android/samples/graphnotificationssample/app/src/androidTest/java/com/microsoft/connecteddevices/graphnotifications/ExampleInstrumentedTest.java b/Android/samples/graphnotificationssample/app/src/androidTest/java/com/microsoft/connecteddevices/graphnotifications/ExampleInstrumentedTest.java new file mode 100644 index 0000000..77c21c3 --- /dev/null +++ b/Android/samples/graphnotificationssample/app/src/androidTest/java/com/microsoft/connecteddevices/graphnotifications/ExampleInstrumentedTest.java @@ -0,0 +1,26 @@ +package com.microsoft.connecteddevices.graphnotifications; + +import android.content.Context; +import android.support.test.InstrumentationRegistry; +import android.support.test.runner.AndroidJUnit4; + +import org.junit.Test; +import org.junit.runner.RunWith; + +import static org.junit.Assert.*; + +/** + * Instrumented test, which will execute on an Android device. + * + * @see Testing documentation + */ +@RunWith(AndroidJUnit4.class) +public class ExampleInstrumentedTest { + @Test + public void useAppContext() throws Exception { + // Context of the app under test. + Context appContext = InstrumentationRegistry.getTargetContext(); + + assertEquals("com.microsoft.connecteddevices.graphnotifications", appContext.getPackageName()); + } +} diff --git a/Android/samples/graphnotificationssample/app/src/main/AndroidManifest.xml b/Android/samples/graphnotificationssample/app/src/main/AndroidManifest.xml new file mode 100644 index 0000000..c40adf4 --- /dev/null +++ b/Android/samples/graphnotificationssample/app/src/main/AndroidManifest.xml @@ -0,0 +1,36 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/Android/samples/graphnotificationssample/app/src/main/java/com/microsoft/connecteddevices/graphnotifications/FCMListenerService.java b/Android/samples/graphnotificationssample/app/src/main/java/com/microsoft/connecteddevices/graphnotifications/FCMListenerService.java new file mode 100644 index 0000000..a37cb0f --- /dev/null +++ b/Android/samples/graphnotificationssample/app/src/main/java/com/microsoft/connecteddevices/graphnotifications/FCMListenerService.java @@ -0,0 +1,73 @@ +/* + * Copyright (C) Microsoft Corporation. All rights reserved. + */ + +package com.microsoft.connecteddevices.graphnotifications; + +import android.content.Intent; +import android.support.annotation.NonNull; +import android.support.v4.content.LocalBroadcastManager; +import android.util.Log; + +import com.google.android.gms.tasks.OnCompleteListener; +import com.google.android.gms.tasks.Task; +import com.google.firebase.iid.FirebaseInstanceId; +import com.google.firebase.iid.InstanceIdResult; +import com.google.firebase.messaging.FirebaseMessagingService; +import com.google.firebase.messaging.RemoteMessage; +import com.microsoft.connecteddevices.core.NotificationReceiver; + +import java.util.Map; + +/** + * Communicates with Firebase Cloud Messaging. + */ +public class FCMListenerService extends FirebaseMessagingService { + private static final String TAG = "FCMListenerService"; + + private static final String RegistrationComplete = "registrationComplete"; + private static final String TOKEN = "TOKEN"; + + private static String s_previousToken = null; + + @Override + public void onCreate() { + FirebaseInstanceId.getInstance().getInstanceId().addOnCompleteListener(new OnCompleteListener() { + @Override + public void onComplete(@NonNull Task task) { + if (task.isSuccessful()) { + String token = task.getResult().getToken(); + if (!token.isEmpty()) { + FCMListenerService.this.onNewToken(token); + } + } + } + }); + } + + /** + * Check whether it's a rome notification or not. + * If it is a rome notification, + * It will notify the apps with the information in the notification. + * @param message FCM class for messaging with a from a data field. + */ + @Override + public void onMessageReceived(RemoteMessage message) { + Log.d(TAG, "From: " + message.getFrom()); + Map data = message.getData(); + if (!NotificationReceiver.Receive(data)) { + Log.d(TAG, "FCM client received a message that was not a Rome notification"); + } + } + + @Override + public void onNewToken(String token) { + if (token != null && !token.equals(s_previousToken)) { + s_previousToken = token; + Intent registrationComplete = new Intent(RegistrationComplete); + registrationComplete.putExtra(TOKEN, token); + LocalBroadcastManager.getInstance(this).sendBroadcast(registrationComplete); + } + } + +} diff --git a/Android/samples/graphnotificationssample/app/src/main/java/com/microsoft/connecteddevices/graphnotifications/MainActivity.java b/Android/samples/graphnotificationssample/app/src/main/java/com/microsoft/connecteddevices/graphnotifications/MainActivity.java new file mode 100644 index 0000000..c0d0c6c --- /dev/null +++ b/Android/samples/graphnotificationssample/app/src/main/java/com/microsoft/connecteddevices/graphnotifications/MainActivity.java @@ -0,0 +1,518 @@ +package com.microsoft.connecteddevices.graphnotifications; + +import android.app.Activity; +import android.content.Context; +import android.os.Bundle; +import android.support.annotation.NonNull; +import android.support.design.widget.TabLayout; +import android.support.v4.app.Fragment; +import android.support.v4.app.FragmentManager; +import android.support.v4.app.FragmentPagerAdapter; +import android.support.v4.view.ViewPager; +import android.support.v7.app.AppCompatActivity; +import android.support.v7.widget.Toolbar; +import android.util.ArrayMap; +import android.view.LayoutInflater; +import android.view.Menu; +import android.view.MenuItem; +import android.view.View; +import android.view.ViewGroup; +import android.widget.ArrayAdapter; +import android.widget.Button; +import android.widget.ListView; +import android.widget.TextView; +import android.widget.Toast; + +import com.microsoft.connecteddevices.base.AsyncOperation; +import com.microsoft.connecteddevices.base.EventListener; +import com.microsoft.connecteddevices.core.Platform; +import com.microsoft.connecteddevices.sampleaccountproviders.AADMSAAccountProvider; +import com.microsoft.connecteddevices.userdata.UserDataFeed; +import com.microsoft.connecteddevices.userdata.UserDataFeedSyncScope; +import com.microsoft.connecteddevices.usernotifications.UserNotification; +import com.microsoft.connecteddevices.usernotifications.UserNotificationChannel; +import com.microsoft.connecteddevices.usernotifications.UserNotificationReadState; +import com.microsoft.connecteddevices.usernotifications.UserNotificationReader; +import com.microsoft.connecteddevices.usernotifications.UserNotificationReaderOptions; +import com.microsoft.connecteddevices.usernotifications.UserNotificationStatus; +import com.microsoft.connecteddevices.usernotifications.UserNotificationUserActionState; + +import java.util.ArrayList; +import java.util.List; +import java.util.Map; + +public class MainActivity extends AppCompatActivity { + + /** + * The {@link android.support.v4.view.PagerAdapter} that will provide + * fragments for each of the sections. We use a + * {@link FragmentPagerAdapter} derivative, which will keep every + * loaded fragment in memory. If this becomes too memory intensive, it + * may be best to switch to a + * {@link android.support.v4.app.FragmentStatePagerAdapter}. + */ + private SectionsPagerAdapter mSectionsPagerAdapter; + + /** + * The {@link ViewPager} that will host the section contents. + */ + private ViewPager mViewPager; + + private static Platform sPlatform; + private static AADMSAAccountProvider sAccountProvider; + private static UserNotificationReader sReader; + private static ArrayList sNewNotifications = new ArrayList<>(); + private static final ArrayList sHistoricalNotifications = new ArrayList<>(); + + @Override + protected void onCreate(Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + setContentView(R.layout.activity_main); + + Toolbar toolbar = findViewById(R.id.toolbar); + setSupportActionBar(toolbar); + // Create the adapter that will return a fragment for each of the three + // primary sections of the activity. + mSectionsPagerAdapter = new SectionsPagerAdapter(getSupportFragmentManager()); + + // Set up the ViewPager with the sections adapter. + mViewPager = findViewById(R.id.container); + mViewPager.setAdapter(mSectionsPagerAdapter); + + TabLayout tabLayout = findViewById(R.id.tabs); + + mViewPager.addOnPageChangeListener(new TabLayout.TabLayoutOnPageChangeListener(tabLayout)); + tabLayout.addOnTabSelectedListener(new TabLayout.ViewPagerOnTabSelectedListener(mViewPager)); + + ensurePlatformInitialized(this); + } + + + @Override + public boolean onCreateOptionsMenu(Menu menu) { + // Inflate the menu; this adds items to the action bar if it is present. + getMenuInflater().inflate(R.menu.menu_main, menu); + return true; + } + + @Override + public boolean onOptionsItemSelected(MenuItem item) { + // Handle action bar item clicks here. The action bar will + // automatically handle clicks on the Home/Up button, so long + // as you specify a parent activity in AndroidManifest.xml. + int id = item.getItemId(); + + //noinspection SimplifiableIfStatement + if (id == R.id.action_settings) { + return true; + } + + return super.onOptionsItemSelected(item); + } + + private static class RunnableManager { + private static Runnable sNewNotificationsUpdated; + private static Runnable sHistoryUpdated; + + static void setNewNotificationsUpdated(Runnable runnable) { + sNewNotificationsUpdated = runnable; + } + + static void setHistoryUpdated(Runnable runnable) { + sHistoryUpdated = runnable; + } + + static Runnable getNewNotificationsUpdated() { + return sNewNotificationsUpdated; + } + + static Runnable getHistoryUpdated() { + return sHistoryUpdated; + } + } + + public static class LoginFragment extends Fragment { + private Button mAadButton; + private Button mMsaButton; + boolean firstCreate = true; + + public LoginFragment() { + } + + public static LoginFragment newInstance() { + return new LoginFragment(); + } + + @Override + public View onCreateView(@NonNull LayoutInflater inflater, ViewGroup container, + Bundle savedInstanceState) { + View rootView = inflater.inflate(R.layout.fragment_main, container, false); + mAadButton = rootView.findViewById(R.id.login_aad_button); + mMsaButton = rootView.findViewById(R.id.login_msa_button); + setState(sAccountProvider.getSignInState()); + if (firstCreate && sAccountProvider.getSignInState() != AADMSAAccountProvider.State.SignedOut) { + firstCreate = false; + MainActivity.setupChannel(getActivity()); + } + + return rootView; + } + + void setState(AADMSAAccountProvider.State loginState) { + switch (loginState) { + case SignedOut: + mAadButton.setEnabled(true); + mAadButton.setText(R.string.login_aad); + mAadButton.setOnClickListener(new View.OnClickListener() { + @Override + public void onClick(View view) { + sAccountProvider.signInAAD().whenComplete(new AsyncOperation.ResultBiConsumer() { + @Override + public void accept(Boolean success, Throwable throwable) throws Throwable { + if (throwable == null && success) { + setState(sAccountProvider.getSignInState()); + MainActivity.setupChannel(getActivity()); + } + } + }); + } + }); + + mMsaButton.setEnabled(true); + mMsaButton.setText(R.string.login_msa); + mMsaButton.setOnClickListener(new View.OnClickListener() { + @Override + public void onClick(View view) { + sAccountProvider.signInMSA(getActivity()).whenComplete(new AsyncOperation.ResultBiConsumer() { + @Override + public void accept(Boolean success, Throwable throwable) throws Throwable { + if (throwable == null && success) { + setState(sAccountProvider.getSignInState()); + MainActivity.setupChannel(getActivity()); + } + } + }); + } + }); + break; + + case SignedInAAD: + mAadButton.setText(R.string.logout); + mAadButton.setOnClickListener(new View.OnClickListener() { + @Override + public void onClick(View view) { + sAccountProvider.signOutAAD(); + setState(sAccountProvider.getSignInState()); + } + }); + mMsaButton.setEnabled(false); + break; + + case SignedInMSA: + mAadButton.setEnabled(false); + mMsaButton.setText(R.string.logout); + mMsaButton.setOnClickListener(new View.OnClickListener() { + @Override + public void onClick(View view) { + sAccountProvider.signOutMSA(getActivity()); + setState(sAccountProvider.getSignInState()); + } + }); + break; + } + } + } + + static class NotificationArrayAdapter extends ArrayAdapter { + NotificationArrayAdapter(Context context, List items) { + super(context, R.layout.notifications_list_item, items); + } + + @Override + public View getView(int position, View convertView, ViewGroup parent) { + final UserNotification notification = sNewNotifications.get(position); + + if (convertView == null) { + convertView = LayoutInflater.from(getContext()).inflate(R.layout.notifications_list_item, parent, false); + } + + TextView idView = convertView.findViewById(R.id.notification_id); + idView.setText(notification.getId()); + + TextView textView = convertView.findViewById(R.id.notification_text); + String content = notification.getContent(); + textView.setText(content); + + convertView.setOnClickListener(new View.OnClickListener() { + @Override + public void onClick(View view) { + notification.setUserActionState(UserNotificationUserActionState.DISMISSED); + notification.saveAsync(); + } + }); + + return convertView; + } + } + + public static class NotificationsFragment extends Fragment { + NotificationArrayAdapter mNotificationArrayAdapter; + public NotificationsFragment() { + } + + /** + * Returns a new instance of this fragment for the given section + * number. + */ + public static NotificationsFragment newInstance() { + return new NotificationsFragment(); + } + + @Override + public View onCreateView(LayoutInflater inflater, ViewGroup container, + Bundle savedInstanceState) { + mNotificationArrayAdapter = new NotificationArrayAdapter(getContext(), sNewNotifications); + RunnableManager.setNewNotificationsUpdated(new Runnable() { + @Override + public void run() { + Toast.makeText(getContext(), "Got a new notification!", Toast.LENGTH_SHORT).show(); + mNotificationArrayAdapter.notifyDataSetChanged(); + } + }); + View rootView = inflater.inflate(R.layout.fragment_notifications, container, false); + ListView listView = rootView.findViewById(R.id.notificationListView); + listView.setAdapter(mNotificationArrayAdapter); + return rootView; + } + } + + static class HistoryArrayAdapter extends ArrayAdapter { + HistoryArrayAdapter(Context context, List items) { + super(context, R.layout.notifications_list_item, items); + } + + @Override + public View getView(int position, View convertView, ViewGroup parent) { + final UserNotification notification = sHistoricalNotifications.get(position); + + if (convertView == null) { + convertView = LayoutInflater.from(getContext()).inflate(R.layout.notifications_list_item, parent, false); + } + + TextView idView = convertView.findViewById(R.id.notification_id); + idView.setText(notification.getId()); + + TextView textView = convertView.findViewById(R.id.notification_text); + textView.setText(notification.getContent()); + + convertView.setOnClickListener(new View.OnClickListener() { + @Override + public void onClick(View view) { + notification.setReadState(UserNotificationReadState.READ); + notification.saveAsync(); + } + }); + + return convertView; + } + } + + public static class HistoryFragment extends Fragment { + private HistoryArrayAdapter mHistoryArrayAdapter; + public HistoryFragment() { + } + + /** + * Returns a new instance of this fragment for the given section + * number. + */ + public static HistoryFragment newInstance() { + return new HistoryFragment(); + } + + @Override + public View onCreateView(LayoutInflater inflater, ViewGroup container, + Bundle savedInstanceState) { + View rootView = inflater.inflate(R.layout.fragment_history, container, false); + mHistoryArrayAdapter = new HistoryArrayAdapter(getContext(), sHistoricalNotifications); + RunnableManager.setHistoryUpdated(new Runnable() { + @Override + public void run() { + mHistoryArrayAdapter.notifyDataSetChanged(); + } + }); + ListView listView = rootView.findViewById(R.id.historyListView); + listView.setAdapter(mHistoryArrayAdapter); + return rootView; + } + } + + /** + * A {@link FragmentPagerAdapter} that returns a fragment corresponding to + * one of the sections/tabs/pages. + */ + private class SectionsPagerAdapter extends FragmentPagerAdapter { + LoginFragment mLoginFragment; + NotificationsFragment mNotificationFragment; + HistoryFragment mHistoryFragment; + + SectionsPagerAdapter(FragmentManager fm) { + super(fm); + } + + @Override + public Fragment getItem(int position) { + switch(position) + { + case 0: + if (mLoginFragment == null) { + mLoginFragment = LoginFragment.newInstance(); + } + + return mLoginFragment; + case 1: + if (mNotificationFragment == null) { + mNotificationFragment = NotificationsFragment.newInstance(); + } + + return mNotificationFragment; + case 2: + if (mHistoryFragment == null) { + mHistoryFragment = HistoryFragment.newInstance(); + } + + return mHistoryFragment; + } + + return null; + } + + @Override + public int getCount() { + // Show 3 total pages. + return 3; + } + } + + static synchronized Platform ensurePlatformInitialized(Context context) { + // First see if we have an existing platform + if (sPlatform != null) { + return sPlatform; + } + + // No existing platform, so we have to create our own + RomeNotificationProvider notificationProvider = new RomeNotificationProvider(context); + final Map msaScopeOverrides = new ArrayMap<>(); + msaScopeOverrides.put("https://activity.windows.com/UserActivity.ReadWrite.CreatedByApp", + new String[] { "https://activity.windows.com/UserActivity.ReadWrite.CreatedByApp", + "https://activity.windows.com/Notifications.ReadWrite.CreatedByApp"}); + sAccountProvider = new AADMSAAccountProvider(Secrets.MSA_CLIENT_ID, msaScopeOverrides, Secrets.AAD_CLIENT_ID, Secrets.AAD_REDIRECT_URI, context); + sPlatform = new Platform(context, sAccountProvider, notificationProvider); + return sPlatform; + } + + static void setupChannel(final Activity activity) { + new Thread(new Runnable() { + @Override + public void run() { + if (sAccountProvider.getUserAccounts().length == 0) { + return; + } + + UserDataFeed dataFeed = UserDataFeed.getForAccount(sAccountProvider.getUserAccounts()[0], sPlatform, Secrets.APP_HOST_NAME); + dataFeed.addSyncScopes(new UserDataFeedSyncScope[]{UserNotificationChannel.getSyncScope()}); + UserNotificationChannel channel = new UserNotificationChannel(dataFeed); + UserNotificationReaderOptions options = new UserNotificationReaderOptions(); + sReader = channel.createReaderWithOptions(options); + sReader.readBatchAsync(Long.MAX_VALUE).thenAccept(new AsyncOperation.ResultConsumer() { + @Override + public void accept(UserNotification[] userNotifications) throws Throwable { + synchronized (sHistoricalNotifications) { + for (UserNotification notification : userNotifications) { + if (notification.getReadState() == UserNotificationReadState.UNREAD) { + sHistoricalNotifications.add(notification); + } + } + } + + if (RunnableManager.getHistoryUpdated() != null) { + activity.runOnUiThread(RunnableManager.getHistoryUpdated()); + } + } + }); + + sReader.addDataChangedListener(new EventListener() { + @Override + public void onEvent(UserNotificationReader userNotificationReader, Void aVoid) { + userNotificationReader.readBatchAsync(Long.MAX_VALUE).thenAccept(new AsyncOperation.ResultConsumer() { + @Override + public void accept(UserNotification[] userNotifications) throws Throwable { + boolean updatedNew = false; + boolean updatedHistorical = false; + synchronized (sHistoricalNotifications) { + for (final UserNotification notification : userNotifications) { + if (notification.getStatus() == UserNotificationStatus.ACTIVE && notification.getReadState() == UserNotificationReadState.UNREAD) { + switch (notification.getUserActionState()) { + case NO_INTERACTION: + // Brand new notification + for (int i = 0; i < sNewNotifications.size(); i++) { + if (sNewNotifications.get(i).getId().equals(notification.getId())) { + sNewNotifications.remove(i); + break; + } + } + + sNewNotifications.add(notification); + updatedNew = true; + break; + case DISMISSED: + // Existing notification we dismissed, move from new -> history + for (int i = 0; i < sNewNotifications.size(); i++) { + if (sNewNotifications.get(i).getId().equals(notification.getId())) { + sNewNotifications.remove(i); + updatedNew = true; + break; + } + } + + for (int i = 0; i < sHistoricalNotifications.size(); i++) { + if (sHistoricalNotifications.get(i).getId().equals(notification.getId())) { + sHistoricalNotifications.remove(i); + break; + } + } + + sHistoricalNotifications.add(notification); + updatedHistorical = true; + break; + default: + // Something unexpected happened, just ignore for future flexibility + } + } else { + // historical item has been updated, should only happen if marked as read + for (int i = 0; i < sHistoricalNotifications.size(); i++) { + if (sHistoricalNotifications.get(i).getId().equals(notification.getId())) { + sHistoricalNotifications.remove(i); + updatedHistorical = true; + break; + } + } + } + } + } + + if (updatedNew && RunnableManager.getNewNotificationsUpdated() != null) { + activity.runOnUiThread(RunnableManager.getNewNotificationsUpdated()); + } + + if (updatedHistorical && RunnableManager.getHistoryUpdated() != null) { + activity.runOnUiThread(RunnableManager.getHistoryUpdated()); + } + } + }); + } + }); + } + }).start(); + } +} diff --git a/Android/samples/graphnotificationssample/app/src/main/java/com/microsoft/connecteddevices/graphnotifications/RomeNotificationProvider.java b/Android/samples/graphnotificationssample/app/src/main/java/com/microsoft/connecteddevices/graphnotifications/RomeNotificationProvider.java new file mode 100644 index 0000000..f8f13bc --- /dev/null +++ b/Android/samples/graphnotificationssample/app/src/main/java/com/microsoft/connecteddevices/graphnotifications/RomeNotificationProvider.java @@ -0,0 +1,121 @@ +package com.microsoft.connecteddevices.graphnotifications; + +import android.content.BroadcastReceiver; +import android.content.Context; +import android.content.Intent; +import android.content.IntentFilter; +import android.support.v4.content.LocalBroadcastManager; +import android.util.Log; + +import com.microsoft.connecteddevices.base.AsyncOperation; +import com.microsoft.connecteddevices.base.EventListener; +import com.microsoft.connecteddevices.core.NotificationProvider; +import com.microsoft.connecteddevices.core.NotificationRegistration; +import com.microsoft.connecteddevices.core.NotificationType; + +import java.util.HashMap; +import java.util.Map; + +public class RomeNotificationProvider extends BroadcastReceiver implements NotificationProvider { + private Map> mListenerMap; + private Long mNextListenerId = 0L; + private NotificationRegistration mNotificationRegistration; + private AsyncOperation mAsync; + private Context mContext; + private static final String RegistrationComplete = "registrationComplete"; + + RomeNotificationProvider(Context context) { + mListenerMap = new HashMap<>(); + mContext = context; + + registerFCMBroadcastReceiver(); + } + + /** + * This function returns Notification Registration after it completes async operation. + * @return Notification Registration. + */ + @Override + public synchronized AsyncOperation getNotificationRegistrationAsync() { + if (mAsync == null) { + mAsync = new AsyncOperation<>(); + } + if (mNotificationRegistration != null) { + mAsync.complete(mNotificationRegistration); + } + return mAsync; + } + + /** + * This function adds new event listener to notification provider. + * @param listener the EventListener. + * @return id next event listener id. + */ + @Override + public synchronized long addNotificationProviderChangedListener( + EventListener listener) { + mListenerMap.put(mNextListenerId, listener); + return mNextListenerId++; + } + + /** + * This function removes the event listener. + * @param id the id corresponds to the event listener that would be removed. + */ + @Override + public synchronized void removeNotificationProviderChangedListener(long id) { + mListenerMap.remove(id); + } + + /** + * When FCM has been registered, this will get fired. + * @param context the application's context. + * @param intent the broadcast intent sent to any interested BroadcastReceiver components. + */ + @Override + public void onReceive(Context context, Intent intent) { + String token = null; + String action = intent.getAction(); + + Log.i("Receiver", "Broadcast received: " + action); + + if (action.equals(RegistrationComplete)) { + token = intent.getExtras().getString("TOKEN"); + } + + if (token == null) { + Log.e("GraphNotifications", + "Got notification that FCM had been registered, but token is null. Was app ID set in FCMRegistrationIntentService?"); + } + + synchronized (this) { + mNotificationRegistration = new NotificationRegistration(NotificationType.FCM, token, Secrets.FCM_SENDER_ID, "GraphNotifications"); + + if (mAsync == null) { + mAsync = new AsyncOperation<>(); + } + mAsync.complete(mNotificationRegistration); + mAsync = new AsyncOperation<>(); + + for (EventListener event : mListenerMap.values()) { + event.onEvent(this, mNotificationRegistration); + } + } + } + /** + * This function is called to start FCM registration service. + * Start FCMRegistrationIntentService to register with FCM. + */ + private void startService() { + Intent registrationIntentService = new Intent(mContext, FCMListenerService.class); + mContext.startService(registrationIntentService); + } + + /** + * This function is called to register FCM. + */ + private void registerFCMBroadcastReceiver() { + LocalBroadcastManager.getInstance(mContext).registerReceiver(this, new IntentFilter(RegistrationComplete)); + startService(); + } +} diff --git a/Android/samples/graphnotificationssample/app/src/main/java/com/microsoft/connecteddevices/graphnotifications/Secrets.java.example b/Android/samples/graphnotificationssample/app/src/main/java/com/microsoft/connecteddevices/graphnotifications/Secrets.java.example new file mode 100644 index 0000000..2005ef0 --- /dev/null +++ b/Android/samples/graphnotificationssample/app/src/main/java/com/microsoft/connecteddevices/graphnotifications/Secrets.java.example @@ -0,0 +1,22 @@ +// +// Copyright (c) Microsoft Corporation. All rights reserved. +// + +package com.microsoft.connecteddevices.graphnotifications; + +class Secrets { +// These come from the converged app registration portal at apps.dev.microsoft.com + // MSA_CLIENT_ID: Id of this app's registration in the MSA portal + // AAD_CLIENT_ID: Id of this app's registration in the Azure portal + // AAD_REDIRECT_URI: A Uri that this app is registered with in the Azure portal. + // AAD is supposed to use this Uri to call the app back after login (currently not true, external requirement) + // And this app is supposed to be able to handle this Uri (currently not true) + // APP_HOST_NAME Cross-device domain of this app's registration + static final String MSA_CLIENT_ID = "<>"; + static final String AAD_CLIENT_ID = "<>"; + static final String AAD_REDIRECT_URI = "<>"; + static final String APP_HOST_NAME = "<>"; + + // Your client's Firebase Cloud Messaging Sender Id + static final String FCM_SENDER_ID = "<>"; +} diff --git a/Android/samples/graphnotificationssample/app/src/main/res/drawable-v24/ic_launcher_foreground.xml b/Android/samples/graphnotificationssample/app/src/main/res/drawable-v24/ic_launcher_foreground.xml new file mode 100644 index 0000000..c7bd21d --- /dev/null +++ b/Android/samples/graphnotificationssample/app/src/main/res/drawable-v24/ic_launcher_foreground.xml @@ -0,0 +1,34 @@ + + + + + + + + + + + diff --git a/Android/samples/graphnotificationssample/app/src/main/res/drawable/ic_launcher_background.xml b/Android/samples/graphnotificationssample/app/src/main/res/drawable/ic_launcher_background.xml new file mode 100644 index 0000000..d5fccc5 --- /dev/null +++ b/Android/samples/graphnotificationssample/app/src/main/res/drawable/ic_launcher_background.xml @@ -0,0 +1,170 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Android/samples/graphnotificationssample/app/src/main/res/layout/activity_main.xml b/Android/samples/graphnotificationssample/app/src/main/res/layout/activity_main.xml new file mode 100644 index 0000000..ba3cb52 --- /dev/null +++ b/Android/samples/graphnotificationssample/app/src/main/res/layout/activity_main.xml @@ -0,0 +1,62 @@ + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Android/samples/graphnotificationssample/app/src/main/res/layout/auth_dialog.xml b/Android/samples/graphnotificationssample/app/src/main/res/layout/auth_dialog.xml new file mode 100644 index 0000000..8039309 --- /dev/null +++ b/Android/samples/graphnotificationssample/app/src/main/res/layout/auth_dialog.xml @@ -0,0 +1,11 @@ + + + + + \ No newline at end of file diff --git a/Android/samples/graphnotificationssample/app/src/main/res/layout/fragment_history.xml b/Android/samples/graphnotificationssample/app/src/main/res/layout/fragment_history.xml new file mode 100644 index 0000000..19aa023 --- /dev/null +++ b/Android/samples/graphnotificationssample/app/src/main/res/layout/fragment_history.xml @@ -0,0 +1,12 @@ + + + + \ No newline at end of file diff --git a/Android/samples/graphnotificationssample/app/src/main/res/layout/fragment_main.xml b/Android/samples/graphnotificationssample/app/src/main/res/layout/fragment_main.xml new file mode 100644 index 0000000..a325c16 --- /dev/null +++ b/Android/samples/graphnotificationssample/app/src/main/res/layout/fragment_main.xml @@ -0,0 +1,33 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/iOS/samples/GraphNotificationsSample/GraphNotificationsSample.entitlements b/iOS/samples/GraphNotificationsSample/GraphNotificationsSample.entitlements new file mode 100644 index 0000000..903def2 --- /dev/null +++ b/iOS/samples/GraphNotificationsSample/GraphNotificationsSample.entitlements @@ -0,0 +1,8 @@ + + + + + aps-environment + development + + diff --git a/iOS/samples/GraphNotificationsSample/HistoryViewController.h b/iOS/samples/GraphNotificationsSample/HistoryViewController.h new file mode 100644 index 0000000..061c529 --- /dev/null +++ b/iOS/samples/GraphNotificationsSample/HistoryViewController.h @@ -0,0 +1,8 @@ +#pragma once + +#import +#import "NotificationsManager.h" + +@interface HistoryViewController : UITableViewController +@end + diff --git a/iOS/samples/GraphNotificationsSample/HistoryViewController.m b/iOS/samples/GraphNotificationsSample/HistoryViewController.m new file mode 100644 index 0000000..aa109fd --- /dev/null +++ b/iOS/samples/GraphNotificationsSample/HistoryViewController.m @@ -0,0 +1,70 @@ + +#import +#import "HistoryViewController.h" +#import "AdaptiveCards/ACFramework.h" + + +@interface HistoryViewController() { +} +@property (nonatomic) NSMutableArray* notifications; +- (void)updateData; +- (void)handleTap:(UITapGestureRecognizer*)recognizer; +@end + +@implementation HistoryViewController + +- (void)viewDidLoad { + [super viewDidLoad]; + // Do any additional setup after loading the view, typically from a nib. + + __weak typeof(self) weakSelf = self; + [[NotificationsManager sharedInstance] addNotificationsChangedListener:^{ + [self updateData]; + dispatch_async(dispatch_get_main_queue(), ^{ + [weakSelf.tableView reloadData]; + }); + }]; + + [self updateData]; +} + +- (NSInteger)numberOfSectionsInTableView:(__unused UITableView*)tableView +{ + return 1; +} + +- (nonnull UITableViewCell*)tableView:(nonnull UITableView*)tableView cellForRowAtIndexPath:(nonnull __unused NSIndexPath*)indexPath +{ + UITableViewCell* cell = [tableView dequeueReusableCellWithIdentifier:@"NotificationCell"]; + MCDUserNotification* notification = self.notifications[indexPath.row]; + + ACOAdaptiveCardParseResult* parseResult = [ACOAdaptiveCard fromJson:notification.content]; + ACRRenderResult* renderResult = [ACRRenderer render:parseResult.card config:[ACOHostConfig new] widthConstraint:0.0f]; + + [cell.contentView addSubview:renderResult.view]; + [cell addGestureRecognizer:[[UITapGestureRecognizer alloc] initWithTarget:self action:@selector(handleTap:)]]; + return cell; +} + +- (NSInteger)tableView:(nonnull __unused UITableView*)tableView numberOfRowsInSection:(__unused NSInteger)section +{ + return self.notifications.count; +} + +- (void)handleTap:(UITapGestureRecognizer *)recognizer { + [[NotificationsManager sharedInstance] readNotificationAtIndex:[self.tableView indexPathForCell:(UITableViewCell*)recognizer.view].row]; +} + +- (void)updateData { + @synchronized (self) { + [self.notifications removeAllObjects]; + for (MCDUserNotification* notification in [NotificationsManager sharedInstance].notifications) { + if (notification.status == MCDUserNotificationStatusActive && notification.readState == MCDUserNotificationReadStateUnread && notification.userActionState != MCDUserNotificationUserActionStateNoInteraction) { + [self.notifications addObject:notification]; + } + } + } +} + +@end + diff --git a/iOS/samples/GraphNotificationsSample/Info.plist b/iOS/samples/GraphNotificationsSample/Info.plist new file mode 100644 index 0000000..16be3b6 --- /dev/null +++ b/iOS/samples/GraphNotificationsSample/Info.plist @@ -0,0 +1,45 @@ + + + + + CFBundleDevelopmentRegion + $(DEVELOPMENT_LANGUAGE) + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + $(PRODUCT_NAME) + CFBundlePackageType + APPL + CFBundleShortVersionString + 1.0 + CFBundleVersion + 1 + LSRequiresIPhoneOS + + UILaunchStoryboardName + LaunchScreen + UIMainStoryboardFile + Main + UIRequiredDeviceCapabilities + + armv7 + + UISupportedInterfaceOrientations + + UIInterfaceOrientationPortrait + UIInterfaceOrientationLandscapeLeft + UIInterfaceOrientationLandscapeRight + + UISupportedInterfaceOrientations~ipad + + UIInterfaceOrientationPortrait + UIInterfaceOrientationPortraitUpsideDown + UIInterfaceOrientationLandscapeLeft + UIInterfaceOrientationLandscapeRight + + + diff --git a/iOS/samples/GraphNotificationsSample/LoginViewController.h b/iOS/samples/GraphNotificationsSample/LoginViewController.h new file mode 100644 index 0000000..f064328 --- /dev/null +++ b/iOS/samples/GraphNotificationsSample/LoginViewController.h @@ -0,0 +1,12 @@ + +#pragma once +#import + +@interface LoginViewController : UIViewController +- (IBAction)loginAAD; +- (IBAction)loginMSA; +@property (strong, nonatomic) IBOutlet UIButton *aadButton; +@property (strong, nonatomic) IBOutlet UIButton *msaButton; + +@end + diff --git a/iOS/samples/GraphNotificationsSample/LoginViewController.m b/iOS/samples/GraphNotificationsSample/LoginViewController.m new file mode 100644 index 0000000..3be406e --- /dev/null +++ b/iOS/samples/GraphNotificationsSample/LoginViewController.m @@ -0,0 +1,102 @@ +// +// DataViewController.m +// GraphNotifications +// +// Created by Allen Ballway on 8/23/18. +// Copyright © 2018 Microsoft. All rights reserved. +// + +#import "LoginViewController.h" +#import "NotificationsManager.h" + +@interface LoginViewController () +@property (nonatomic) AADMSAAccountProvider* accountProvider; +@end + +@implementation LoginViewController + +- (void)viewDidLoad { + [super viewDidLoad]; + // Do any additional setup after loading the view, typically from a nib. + + self.accountProvider = [NotificationsManager sharedInstance].accountProvider; + [self setButtonTextForState:self.accountProvider.signInState]; +} + +- (IBAction)loginAAD { + __weak typeof(self) weakSelf = self; + switch (self.accountProvider.signInState) { + case AADMSAAccountProviderSignInStateSignedInAAD: { + [self.accountProvider signOutWithCompletionCallback:^(BOOL successful, SampleAccountActionFailureReason reason) { + if (successful) { + [weakSelf setButtonTextForState:weakSelf.accountProvider.signInState]; + } else { + NSLog(@"Failed to sign out AAD with reason %ld", reason); + } + }]; + break; + } + case AADMSAAccountProviderSignInStateSignedOut: { + [self.accountProvider signInAADWithCompletionCallback:^(BOOL successful, SampleAccountActionFailureReason reason) { + if (successful) { + [weakSelf setButtonTextForState:weakSelf.accountProvider.signInState]; + } else { + NSLog(@"Failed to sign in with AAD with reason %ld", reason); + } + }]; + break; + } + default: + // Do nothing + break; + } +} + +- (IBAction)loginMSA { + __weak typeof(self) weakSelf = self; + switch (self.accountProvider.signInState) { + case AADMSAAccountProviderSignInStateSignedInMSA: { + [self.accountProvider signOutWithCompletionCallback:^(BOOL successful, SampleAccountActionFailureReason reason) { + if (successful) { + [weakSelf setButtonTextForState:weakSelf.accountProvider.signInState]; + } else { + NSLog(@"Failed to sign out MSA with reason %ld", reason); + } + }]; + break; + } + case AADMSAAccountProviderSignInStateSignedOut: { + [self.accountProvider signInMSAWithCompletionCallback:^(BOOL successful, SampleAccountActionFailureReason reason) { + if (successful) { + [weakSelf setButtonTextForState:weakSelf.accountProvider.signInState]; + } else { + NSLog(@"Failed to sign in with MSA with reason %ld", reason); + } + }]; + break; + } + default: + // Do nothing + break; + } +} + +- (void)setButtonTextForState:(AADMSAAccountProviderSignInState)state { + dispatch_async(dispatch_get_main_queue(), ^{ + switch (state) { + case AADMSAAccountProviderSignInStateSignedOut: + [self.aadButton setTitle:@"Login Work/School Account" forState:UIControlStateNormal]; + [self.msaButton setTitle:@"Login Personal Account" forState:UIControlStateNormal]; + break; + case AADMSAAccountProviderSignInStateSignedInAAD: + [self.aadButton setTitle:@"Logout" forState:UIControlStateNormal]; + [self.msaButton setTitle:@"" forState:UIControlStateNormal]; + break; + case AADMSAAccountProviderSignInStateSignedInMSA: + [self.aadButton setTitle:@"" forState:UIControlStateNormal]; + [self.msaButton setTitle:@"Logout" forState:UIControlStateNormal]; + break; + } + }); +} +@end diff --git a/iOS/samples/GraphNotificationsSample/ModelController.h b/iOS/samples/GraphNotificationsSample/ModelController.h new file mode 100644 index 0000000..01ffa95 --- /dev/null +++ b/iOS/samples/GraphNotificationsSample/ModelController.h @@ -0,0 +1,19 @@ +// +// ModelController.h +// GraphNotifications +// +// Created by Allen Ballway on 8/23/18. +// Copyright © 2018 Microsoft. All rights reserved. +// + +#import + +@class LoginViewController; + +@interface ModelController : NSObject + +- (UIViewController *)viewControllerAtIndex:(NSUInteger)index storyboard:(UIStoryboard *)storyboard; +- (NSUInteger)indexOfViewController:(UIViewController *)viewController; + +@end + diff --git a/iOS/samples/GraphNotificationsSample/ModelController.m b/iOS/samples/GraphNotificationsSample/ModelController.m new file mode 100644 index 0000000..fa5afa4 --- /dev/null +++ b/iOS/samples/GraphNotificationsSample/ModelController.m @@ -0,0 +1,61 @@ +#import "ModelController.h" +#import "LoginViewController.h" + + + +@interface ModelController () + +@end + +@implementation ModelController + +- (instancetype)init { + self = [super init]; + return self; +} + +- (UIViewController *)viewControllerAtIndex:(NSUInteger)index storyboard:(UIStoryboard *)storyboard { + + switch(index) { + case 0: { + LoginViewController *dataViewController = [storyboard instantiateViewControllerWithIdentifier:@"LoginViewController"]; + return dataViewController; + } + default: + return nil; + } +} + + +- (NSUInteger)indexOfViewController:(UIViewController *)viewController { + if ([viewController isKindOfClass:[LoginViewController class]]) { + return 0; + } + + return NSNotFound; +} + + +#pragma mark - Page View Controller Data Source + +- (UIViewController *)pageViewController:(UIPageViewController *)pageViewController viewControllerBeforeViewController:(UIViewController *)viewController +{ + NSUInteger index = [self indexOfViewController:viewController]; + if ((index == 0) || (index == NSNotFound)) { + return nil; + } + + return [self viewControllerAtIndex:--index storyboard:viewController.storyboard]; +} + +- (UIViewController *)pageViewController:(UIPageViewController *)pageViewController viewControllerAfterViewController:(UIViewController *)viewController +{ + NSUInteger index = [self indexOfViewController:viewController]; + if (index == NSNotFound || index >= 2) { + return nil; + } + + return [self viewControllerAtIndex:++index storyboard:viewController.storyboard]; +} + +@end diff --git a/iOS/samples/GraphNotificationsSample/NotificationProvider.h b/iOS/samples/GraphNotificationsSample/NotificationProvider.h new file mode 100644 index 0000000..260bc50 --- /dev/null +++ b/iOS/samples/GraphNotificationsSample/NotificationProvider.h @@ -0,0 +1,19 @@ +// +// Copyright (c) Microsoft Corporation. All rights reserved. +// + +#pragma once + +#import +#import + +// @brief provides an sample implementation of MCDNotificationProvider +@interface NotificationProvider : NSObject + +// @brief get the shared instance of MCDNotificationProvider ++ (nullable instancetype)sharedInstance; + +// @brief class method update the notification registration provider with new notification registration ++ (void)updateNotificationRegistration:(nonnull MCDNotificationRegistration*)notificationRegistration; + +@end diff --git a/iOS/samples/GraphNotificationsSample/NotificationProvider.m b/iOS/samples/GraphNotificationsSample/NotificationProvider.m new file mode 100644 index 0000000..d05f53f --- /dev/null +++ b/iOS/samples/GraphNotificationsSample/NotificationProvider.m @@ -0,0 +1,67 @@ +// +// Copyright (c) Microsoft Corporation. All rights reserved. +// + +#import "NotificationProvider.h" +#import + +@implementation NotificationProvider +{ + MCDRegistrationUpdatedEvent* _registrationUpdated; + MCDNotificationRegistration* _notificationRegistration; +} + ++ (instancetype)sharedInstance +{ + static NotificationProvider* sharedInstance = nil; + + static dispatch_once_t onceToken; + dispatch_once(&onceToken, ^{ sharedInstance = [[NotificationProvider alloc] init]; }); + + return sharedInstance; +} + ++ (void)updateNotificationRegistration:(MCDNotificationRegistration*)notificationRegistration +{ + NSLog(@"Raise notification registration changed event\nType: %ld\nApplication: %@", (long)notificationRegistration.type, + notificationRegistration.applicationId); + dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0), + ^{ [[NotificationProvider sharedInstance] _updateNotificationRegistration:notificationRegistration]; }); +} + +// MCDNotificationProvider +@synthesize registrationUpdated = _registrationUpdated; + +- (void)getNotificationRegistrationAsync:(nonnull void (^)(MCDNotificationRegistration* _Nullable, NSError* _Nullable))completionBlock +{ + dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0), ^{ completionBlock(_notificationRegistration, nil); }); +} + +- (instancetype)init +{ + if (self = [super init]) + { + @try + { + _registrationUpdated = [MCDRegistrationUpdatedEvent new]; + } + @catch(NSException* e) { + NSLog(@"Failed to create new MCDRegistrationUpdatedEvent with exception %@", e.description); + } + } + + return self; +} + +- (void)_updateNotificationRegistration:(MCDNotificationRegistration*)notificationRegistration +{ + _notificationRegistration = notificationRegistration; + @try + { + [_registrationUpdated raiseWithNotificationRegistration:notificationRegistration]; + } + @catch(NSException* e) { + NSLog(@"Failed to update notification registration with exception %@", e.description); + } +} +@end diff --git a/iOS/samples/GraphNotificationsSample/NotificationsManager.h b/iOS/samples/GraphNotificationsSample/NotificationsManager.h new file mode 100644 index 0000000..4e45afd --- /dev/null +++ b/iOS/samples/GraphNotificationsSample/NotificationsManager.h @@ -0,0 +1,22 @@ + +#pragma once + +#import +#import +#import +#import +#import "AADMSAAccountProvider.h" + +@interface NotificationsManager : NSObject ++ (instancetype)startWithAccountProvider:(AADMSAAccountProvider*)accountProvider platform:(MCDPlatform*)platform; ++ (instancetype)sharedInstance; + +@property (nonatomic, readonly) AADMSAAccountProvider* accountProvider; +@property (nonatomic, readonly) NSArray* notifications; + +- (NSInteger)addNotificationsChangedListener:(void(^)(void))listener; +- (void)removeListener:(NSInteger)token; +- (void)forceRead; +- (void)dismissNotificationAtIndex:(NSUInteger)index; +- (void)readNotificationAtIndex:(NSUInteger)index; +@end diff --git a/iOS/samples/GraphNotificationsSample/NotificationsManager.m b/iOS/samples/GraphNotificationsSample/NotificationsManager.m new file mode 100644 index 0000000..678fcc3 --- /dev/null +++ b/iOS/samples/GraphNotificationsSample/NotificationsManager.m @@ -0,0 +1,138 @@ + +#import "NotificationsManager.h" + +static NotificationsManager* s_manager; + +@interface NotificationsManager () { + NSInteger _listenerValue; + NSUInteger _readerRegistrationToken; + NSMutableArray* _notifications; +} + +@property (nonatomic) NSMutableDictionary* listenerMap; +@property (nonatomic) MCDPlatform* platform; +@property (nonatomic) MCDUserNotificationChannel* channel; +@property (nonatomic) MCDUserNotificationReader* reader; +- (instancetype)initWithAccountProvider:(id)accountProvider platform:(MCDPlatform*)platform; +@end + +@implementation NotificationsManager +- (instancetype)initWithAccountProvider:(AADMSAAccountProvider*)accountProvider platform:(MCDPlatform*)platform { + if (self = [super init]) { + _listenerValue = 0; + _listenerMap = [NSMutableDictionary dictionary]; + _accountProvider = accountProvider; + _platform = platform; + + __weak typeof(self) weakSelf = self; + [_accountProvider.userAccountChanged subscribe:^ { + if ([weakSelf.accountProvider getUserAccounts].count > 0) { + [weakSelf setupWithAccount:[weakSelf.accountProvider getUserAccounts][0]]; + } else { + for (void (^listener)(void) in weakSelf.listenerMap.allValues) { + listener(); + } + } + }]; + } + + return self; +} + +- (void)setupWithAccount:(MCDUserAccount*)account { + @synchronized (self) { + MCDUserDataFeed* dataFeed = [MCDUserDataFeed userDataFeedForAccount:account platform:_platform activitySourceHost:APP_HOST_NAME]; + self.channel = [MCDUserNotificationChannel userNotificationChannelWithUserDataFeed:dataFeed]; + self.reader = [self.channel createReader]; + + __weak typeof(self) weakSelf; + _readerRegistrationToken = [self.reader addDataChangedListener:^(__unused MCDUserNotificationReader* source) { + [weakSelf forceRead]; + }]; + } + +} + +- (void)forceRead { + [self.reader readBatchAsyncWithMaxSize:0 completion:^(NSArray * _Nullable notifications, NSError * _Nullable error) { + if (error) { + NSLog(@"Failed to read batch with error %@", error); + } else { + [self _handleNotifications:notifications]; + for (void (^listener)(void) in self.listenerMap.allValues) { + listener(); + } + } + }]; +} + +- (void)readNotificationAtIndex:(NSUInteger)index { + @synchronized (self) { + self.notifications[index].readState = MCDUserNotificationReadStateRead; + [self.notifications[index] saveAsync:^(__unused MCDUserNotificationUpdateResult * _Nullable result, __unused NSError * _Nullable err) { + // Do Nothing + }]; + } +} + +- (void)dismissNotificationAtIndex:(NSUInteger)index { + @synchronized (self) { + self.notifications[index].userActionState = MCDUserNotificationUserActionStateDismissed; + [self.notifications[index] saveAsync:^(__unused MCDUserNotificationUpdateResult * _Nullable result, __unused NSError * _Nullable err) { + // Do Nothing + }]; + } +} + ++ (instancetype)startWithAccountProvider:(AADMSAAccountProvider*)accountProvider platform:(MCDPlatform*)platform { + @synchronized (self) { + if (s_manager == nil) { + s_manager = [[self alloc] initWithAccountProvider:accountProvider platform:platform]; + } + + return s_manager; + } +} + ++ (instancetype)sharedInstance { + @synchronized (self) { + return s_manager; + } +} + +- (NSInteger)addNotificationsChangedListener:(void(^)(void))listener { + @synchronized (self) { + _listenerMap[[NSNumber numberWithInteger:(++_listenerValue)]] = listener; + return _listenerValue; + } +} + +- (void)removeListener:(NSInteger)token { + @synchronized (self) { + [_listenerMap removeObjectForKey:[NSNumber numberWithInteger:token]]; + } +} + +- (NSArray*)notifications { + return _notifications; +} + +- (void)_handleNotifications:(NSArray*)notifications { + @synchronized (self) { + for (MCDUserNotification* notification in notifications) { + NSUInteger index = [_notifications indexOfObjectPassingTest:^BOOL(MCDUserNotification * _Nonnull obj, __unused NSUInteger idx, __unused BOOL * _Nonnull stop) { + return [obj.notificationId isEqualToString:notification.notificationId]; + }]; + + if (index != NSNotFound) { + [_notifications removeObjectAtIndex:index]; + } + + if (notification.status == MCDUserNotificationStatusActive) { + [_notifications insertObject:notification atIndex:0]; + } + } + } +} + +@end diff --git a/iOS/samples/GraphNotificationsSample/NotificationsViewController.h b/iOS/samples/GraphNotificationsSample/NotificationsViewController.h new file mode 100644 index 0000000..b9bdf49 --- /dev/null +++ b/iOS/samples/GraphNotificationsSample/NotificationsViewController.h @@ -0,0 +1,7 @@ +#pragma once + +#import +#import "NotificationsManager.h" + +@interface NotificationsViewController : UITableViewController +@end diff --git a/iOS/samples/GraphNotificationsSample/NotificationsViewController.m b/iOS/samples/GraphNotificationsSample/NotificationsViewController.m new file mode 100644 index 0000000..ca59cbe --- /dev/null +++ b/iOS/samples/GraphNotificationsSample/NotificationsViewController.m @@ -0,0 +1,69 @@ + +#import +#import "NotificationsViewController.h" +#import "AdaptiveCards/ACFramework.h" + + +@interface NotificationsViewController() { +} +@property (nonatomic) NSMutableArray* notifications; +- (void)updateData; +- (void)handleTap:(UITapGestureRecognizer*)recognizer; +@end + +@implementation NotificationsViewController + +- (void)viewDidLoad { + [super viewDidLoad]; + // Do any additional setup after loading the view, typically from a nib. + + __weak typeof(self) weakSelf = self; + [[NotificationsManager sharedInstance] addNotificationsChangedListener:^{ + [self updateData]; + dispatch_async(dispatch_get_main_queue(), ^{ + [weakSelf.tableView reloadData]; + }); + }]; + + [self updateData]; +} + +- (NSInteger)numberOfSectionsInTableView:(__unused UITableView*)tableView +{ + return 1; +} + +- (nonnull UITableViewCell*)tableView:(nonnull UITableView*)tableView cellForRowAtIndexPath:(nonnull __unused NSIndexPath*)indexPath +{ + UITableViewCell* cell = [tableView dequeueReusableCellWithIdentifier:@"NotificationCell"]; + MCDUserNotification* notification = self.notifications[indexPath.row]; + + ACOAdaptiveCardParseResult* parseResult = [ACOAdaptiveCard fromJson:notification.content]; + ACRRenderResult* renderResult = [ACRRenderer render:parseResult.card config:[ACOHostConfig new] widthConstraint:0.0f]; + + [cell.contentView addSubview:renderResult.view]; + [cell addGestureRecognizer:[[UITapGestureRecognizer alloc] initWithTarget:self action:@selector(handleTap:)]]; + return cell; +} + +- (NSInteger)tableView:(nonnull __unused UITableView*)tableView numberOfRowsInSection:(__unused NSInteger)section +{ + return self.notifications.count; +} + +- (void)handleTap:(UITapGestureRecognizer *)recognizer { + [[NotificationsManager sharedInstance] dismissNotificationAtIndex:[self.tableView indexPathForCell:(UITableViewCell*)recognizer.view].row]; +} + +- (void)updateData { + @synchronized (self) { + [self.notifications removeAllObjects]; + for (MCDUserNotification* notification in [NotificationsManager sharedInstance].notifications) { + if (notification.status == MCDUserNotificationStatusActive && notification.readState == MCDUserNotificationReadStateUnread && notification.userActionState == MCDUserNotificationUserActionStateNoInteraction) { + [self.notifications addObject:notification]; + } + } + } +} + +@end diff --git a/iOS/samples/GraphNotificationsSample/RootViewController.h b/iOS/samples/GraphNotificationsSample/RootViewController.h new file mode 100644 index 0000000..9c9f160 --- /dev/null +++ b/iOS/samples/GraphNotificationsSample/RootViewController.h @@ -0,0 +1,16 @@ +// +// RootViewController.h +// GraphNotifications +// +// Created by Allen Ballway on 8/23/18. +// Copyright © 2018 Microsoft. All rights reserved. +// + +#import + +@interface RootViewController : UIViewController + +@property (strong, nonatomic) UIPageViewController *pageViewController; + +@end + diff --git a/iOS/samples/GraphNotificationsSample/RootViewController.m b/iOS/samples/GraphNotificationsSample/RootViewController.m new file mode 100644 index 0000000..1c1ae92 --- /dev/null +++ b/iOS/samples/GraphNotificationsSample/RootViewController.m @@ -0,0 +1,109 @@ +// +// RootViewController.m +// GraphNotifications +// +// Created by Allen Ballway on 8/23/18. +// Copyright © 2018 Microsoft. All rights reserved. +// + +#import "RootViewController.h" +#import "ModelController.h" +#import "LoginViewController.h" +#import "NotificationsManager.h" +#import "NotificationProvider.h" + +@interface RootViewController () + +@property (readonly, strong, nonatomic) ModelController *modelController; +@end + +@implementation RootViewController + +@synthesize modelController = _modelController; + +- (void)viewDidLoad { + [super viewDidLoad]; + + AADMSAAccountProvider* accountProvider = [[AADMSAAccountProvider alloc] initWithMsaClientId:MSA_CLIENT_ID msaScopeOverrides:@{@"https://activity.windows.com/UserActivity.ReadWrite.CreatedByApp" : @[@"https://activity.windows.com/UserActivity.ReadWrite.CreatedByApp", @"https://activity.windows.com/UserNotification.ReadWrite.CreatedByApp"]} aadApplicationId:AAD_CLIENT_ID aadRedirectUri:AAD_REDIRECT_URI]; + + NotificationProvider* notificationProvider = [NotificationProvider sharedInstance]; + + MCDPlatform* platform = [MCDPlatform platformWithAccountProvider:accountProvider notificationProvider:notificationProvider]; + + [NotificationsManager startWithAccountProvider:accountProvider platform:platform]; + + // Do any additional setup after loading the view, typically from a nib. + // Configure the page view controller and add it as a child view controller. + self.pageViewController = [[UIPageViewController alloc] initWithTransitionStyle:UIPageViewControllerTransitionStylePageCurl navigationOrientation:UIPageViewControllerNavigationOrientationHorizontal options:nil]; + self.pageViewController.delegate = self; + + UIViewController *startingViewController = [self.modelController viewControllerAtIndex:0 storyboard:self.storyboard]; + NSArray *viewControllers = @[startingViewController]; + [self.pageViewController setViewControllers:viewControllers direction:UIPageViewControllerNavigationDirectionForward animated:NO completion:nil]; + + self.pageViewController.dataSource = self.modelController; + + [self addChildViewController:self.pageViewController]; + [self.view addSubview:self.pageViewController.view]; + + // Set the page view controller's bounds using an inset rect so that self's view is visible around the edges of the pages. + CGRect pageViewRect = self.view.bounds; + if ([[UIDevice currentDevice] userInterfaceIdiom] == UIUserInterfaceIdiomPad) { + pageViewRect = CGRectInset(pageViewRect, 40.0, 40.0); + } + self.pageViewController.view.frame = pageViewRect; + + [self.pageViewController didMoveToParentViewController:self]; +} + + +- (void)didReceiveMemoryWarning { + [super didReceiveMemoryWarning]; + // Dispose of any resources that can be recreated. +} + + +- (ModelController *)modelController { + // Return the model controller object, creating it if necessary. + // In more complex implementations, the model controller may be passed to the view controller. + if (!_modelController) { + _modelController = [[ModelController alloc] init]; + } + return _modelController; +} + + +#pragma mark - UIPageViewController delegate methods + +- (UIPageViewControllerSpineLocation)pageViewController:(UIPageViewController *)pageViewController spineLocationForInterfaceOrientation:(UIInterfaceOrientation)orientation { + if (UIInterfaceOrientationIsPortrait(orientation) || ([[UIDevice currentDevice] userInterfaceIdiom] == UIUserInterfaceIdiomPhone)) { + // In portrait orientation or on iPhone: Set the spine position to "min" and the page view controller's view controllers array to contain just one view controller. Setting the spine position to 'UIPageViewControllerSpineLocationMid' in landscape orientation sets the doubleSided property to YES, so set it to NO here. + + UIViewController *currentViewController = self.pageViewController.viewControllers[0]; + NSArray *viewControllers = @[currentViewController]; + [self.pageViewController setViewControllers:viewControllers direction:UIPageViewControllerNavigationDirectionForward animated:YES completion:nil]; + + self.pageViewController.doubleSided = NO; + return UIPageViewControllerSpineLocationMin; + } + + // In landscape orientation: Set set the spine location to "mid" and the page view controller's view controllers array to contain two view controllers. If the current page is even, set it to contain the current and next view controllers; if it is odd, set the array to contain the previous and current view controllers. + LoginViewController *currentViewController = self.pageViewController.viewControllers[0]; + NSArray *viewControllers = nil; + + NSUInteger indexOfCurrentViewController = [self.modelController indexOfViewController:currentViewController]; + if (indexOfCurrentViewController == 0 || indexOfCurrentViewController % 2 == 0) { + UIViewController *nextViewController = [self.modelController pageViewController:self.pageViewController viewControllerAfterViewController:currentViewController]; + viewControllers = @[currentViewController, nextViewController]; + } else { + UIViewController *previousViewController = [self.modelController pageViewController:self.pageViewController viewControllerBeforeViewController:currentViewController]; + viewControllers = @[previousViewController, currentViewController]; + } + [self.pageViewController setViewControllers:viewControllers direction:UIPageViewControllerNavigationDirectionForward animated:YES completion:nil]; + + + return UIPageViewControllerSpineLocationMid; +} + + +@end diff --git a/iOS/samples/GraphNotificationsSample/SampleAccountProviders/AADAccountProvider.h b/iOS/samples/GraphNotificationsSample/SampleAccountProviders/AADAccountProvider.h new file mode 100644 index 0000000..35749cb --- /dev/null +++ b/iOS/samples/GraphNotificationsSample/SampleAccountProviders/AADAccountProvider.h @@ -0,0 +1,25 @@ +// +// Copyright (c) Microsoft Corporation. All rights reserved. +// + +#pragma once + +#import +#import +#import + +#import "SingleUserAccountProvider.h" + +// @brief MCDUserAccountProvider that performs a log in/out flow using ADAL. +// Supports a single AAD user account. +// For getAccessTokenForUserAccountIdAsync: and onAccessTokenError:, because of ADAL limitations, only the first scope in scopes[] is used +@interface AADAccountProvider : NSObject + +// @brief clientId is a guid from the app's registration in the azure portal +// redirectUri is a Uri specified in the same portal +- (nullable instancetype)initWithClientId:(nonnull NSString*)clientId redirectUri:(nonnull NSURL*)redirectUri; + +@property(readonly, nonatomic, copy, nonnull) NSString* clientId; +@property(readonly, nonatomic, copy, nonnull) NSURL* redirectUri; + +@end diff --git a/iOS/samples/GraphNotificationsSample/SampleAccountProviders/AADAccountProvider.m b/iOS/samples/GraphNotificationsSample/SampleAccountProviders/AADAccountProvider.m new file mode 100644 index 0000000..50a68d7 --- /dev/null +++ b/iOS/samples/GraphNotificationsSample/SampleAccountProviders/AADAccountProvider.m @@ -0,0 +1,329 @@ +// +// Copyright (c) Microsoft Corporation. All rights reserved. +// + +#import "AADAccountProvider.h" + +#import + +static NSString* const AADAccountProviderExceptionName = @"AADAccountProviderException"; + +/** + * Notes about AAD/ADAL: + * - Resource An Azure web service/app, such as https://graph.windows.net, or a CDP service. + * - Scope Individual permissions within a resource + * - Access Token A standard JSON web token for a given scope. + * This is the actual token/user ticket used to authenticate with CDP services. + * https://oauth.net/2/ + * https://www.oauth.com/oauth2-servers/access-tokens/ + * - Refresh token: A standard OAuth refresh token. + * Lasts longer than access tokens, and is used to request new access tokens/refresh access tokens when they expire. + * ADAL manages this automatically. + * https://oauth.net/2/grant-types/refresh-token/ + * - MRRT Multiresource refresh token. A refresh token that can be used to fetch access tokens for more than one resource. + * Getting one requires the user consent to all the covered resources. ADAL manages this automatically. + */ +@interface AADAccountProvider () +{ + ADAuthenticationContext* _authContext; + ADTokenCacheItem* _tokenCacheItem; +} +@end + +@implementation AADAccountProvider +@synthesize userAccountChanged = _userAccountChanged; + +- (instancetype)initWithClientId:(NSString*)clientId redirectUri:(NSURL*)redirectUri +{ + if (self = [super init]) + { + _clientId = [clientId copy]; + _redirectUri = [redirectUri copy]; + _userAccountChanged = [MCDUserAccountChangedEvent new]; + +#if TARGET_OS_IPHONE + // Don't share token cache between applications, only need them to be cached for this application + // Without this, the MRRT is not cached, and the acquireTokenSilentWithResource: in getAccessToken + // always fails with AD_ERROR_SERVER_USER_INPUT_NEEDED + [[ADAuthenticationSettings sharedInstance] setDefaultKeychainGroup:nil]; +#endif + + ADAuthenticationError* error = nil; + _authContext = + [ADAuthenticationContext authenticationContextWithAuthority:@"https://login.microsoftonline.com/common" error:&error]; + if (error) + { + NSLog(@"Error creating ADAuthenticationContext for AADAccountProvider: %@.", error); + return nil; + } + + NSLog(@"Checking if previous AADAccountProvider session can be loaded..."); +#if TARGET_OS_IPHONE + NSArray* tokenCacheItems = [[ADKeychainTokenCache defaultKeychainCache] allItems:nil]; +#else + NSArray* tokenCacheItems = [[ADTokenCache defaultCache] allItems:nil]; +#endif + if (tokenCacheItems.count > 0) + { + for (ADTokenCacheItem* item in tokenCacheItems) + { + if (item.isMultiResourceRefreshToken && [_clientId isEqualToString:item.clientId]) + { + _tokenCacheItem = item; + break; + } + } + + if (_tokenCacheItem) + { + NSLog(@"Loaded previous AADAccountProvider session, starting as signed in."); + } + else + { + NSLog(@"No previous AADAccountProvider session could be loaded, starting as signed out."); + } + } + } + + return self; +} + +- (void)_raiseAccountChangedEvent +{ + NSLog(@"Raise Account changed event"); + dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0), ^{ + // fire event on a different thread + [self.userAccountChanged raise]; + }); +} + +- (BOOL)signedIn +{ + @synchronized(self) + { + return _tokenCacheItem != nil; + } +} + +- (void)signInWithCompletionCallback:(SampleAccountProviderCompletionBlock)callback +{ + if (self.signedIn) + { + callback(NO, SampleAccountActionFailureReasonAlreadySignedIn); + return; + } + + // If the user has not previously consented for this default resource for this app, + // the interactive flow will ask for user consent for all resources used by the app. + // If the user previously consented to this resource on this app, and more resources are added to the app later on, + // a consent prompt for all app resources will be raised when an access token for a new resource is requested - + // see getAccessTokenForUserAccountIdAsync: + NSString* defaultResource = @"https://graph.windows.net"; + + [_authContext acquireTokenWithResource:defaultResource + clientId:_clientId + redirectUri:_redirectUri + completionBlock:^(ADAuthenticationResult* result) { + switch (result.status) + { + case AD_SUCCEEDED: + { + @synchronized(self) + { + _tokenCacheItem = result.tokenCacheItem; + } + [self _raiseAccountChangedEvent]; + callback(YES, SampleAccountActionNoFailure); + break; + } + case AD_USER_CANCELLED: + { + callback(NO, SampleAccountActionFailureReasonUserCancelled); + break; + } + case AD_FAILED: + default: + { + NSLog(@"Error occurred in ADAL when signing in to an AAD account. Status: %u, Error: %@", result.status, + result.error); + callback(NO, SampleAccountActionFailureReasonADAL); + break; + } + } + }]; +} + +- (void)signOutWithCompletionCallback:(SampleAccountProviderCompletionBlock)callback +{ + @synchronized(self) + { + if (!self.signedIn) + { + callback(NO, SampleAccountActionFailureReasonAlreadySignedOut); + return; + } + + ADAuthenticationError* error; +#if TARGET_OS_IPHONE + BOOL removed = [[ADKeychainTokenCache defaultKeychainCache] removeAllForClientId:_clientId error:&error]; +#else + // The above convenience method does not exist on OSX + BOOL removed; + NSArray* tokenCacheItems = [[ADTokenCache defaultCache] allItems:&error]; + if (!error) + { + for (ADTokenCacheItem* item in tokenCacheItems) + { + if ([item.clientId isEqualToString:_clientId]) + { + removed = [[ADTokenCache defaultCache] removeItem:item error:&error]; + + if (!removed || error) + { + break; + } + } + } + } +#endif + + if (!removed || error) + { + NSLog(@"Failed to remove token from ADAL cache, error %@", error); + callback(NO, SampleAccountActionFailureReasonADAL); + return; + } + + // Delete cookies + NSArray* cookieNamesToDelete = + @[ @"SignInStateCookie", @"ESTSAUTHPERSISTENT", @"ESTSAUTHLIGHT", @"ESTSAUTH", @"ESTSSC" ]; + + NSHTTPCookieStorage* cookieJar = [NSHTTPCookieStorage sharedHTTPCookieStorage]; + for (NSHTTPCookie* cookie in [cookieJar cookies]) + { + if ([cookieNamesToDelete containsObject:cookie.name]) + { + [cookieJar deleteCookie:cookie]; + } + } + + _tokenCacheItem = nil; + } + + [self _raiseAccountChangedEvent]; + callback(YES, SampleAccountActionNoFailure); +} + +- (void)getAccessTokenForUserAccountIdAsync:(NSString*)accountId + scopes:(NSArray*)scopes + completion:(void (^)(MCDAccessTokenResult*, NSError*))completionBlock +{ + @synchronized(self) + { + if (!self.signedIn || ![accountId isEqualToString:_tokenCacheItem.userInformation.uniqueId]) + { + completionBlock(nil, [NSError errorWithDomain:@"AADAccountProvider" + code:0 + userInfo:@{ + @"Reason" : @"AADAccountProvider does not provide this account." + }]); + return; + } + + // Try to fetch the token silently in the background, escalating to the ui thread if needed for a unique case (see below) + __weak __block void (^weakAdalCallback)(ADAuthenticationResult*); // __weak __block is needed for recursive blocks under ARC + __block void (^adalCallback)(ADAuthenticationResult*) = ^void(ADAuthenticationResult* adalResult) { + MCDAccessTokenResult* result; + NSError* error; + + switch (adalResult.status) + { + case AD_SUCCEEDED: + { + result = + [[MCDAccessTokenResult alloc] initWithAccessToken:adalResult.accessToken status:MCDAccessTokenRequestStatusSuccess]; + break; + } + case AD_USER_CANCELLED: + { + error = [NSError errorWithDomain:@"AADAccountProvider" code:0 userInfo:@{ @"Reason" : @"Cancelled by user." }]; + break; + } + case AD_FAILED: + default: + { + if (adalResult.error.code == AD_ERROR_SERVER_USER_INPUT_NEEDED) + { + // This error only returns from acquireTokenSilentWithResource: when an interactive prompt is needed. + // ADAL has an MRRT, but the user has not consented for this resource/the MRRT does not cover this resource. + // Usually, users consent for all resources the app needs during the interactive flow in signInWith...: + // However, if the app adds new resources after the user consented previously, signIn will not prompt. + // Escalate to the UI thread and do an interactive flow, + // which should raise a new consent prompt for all current app resources. + NSLog(@"A resource was requested that the user did not previously consent to. " + @"Attempting to raise an interactive consent prompt."); + + dispatch_async(dispatch_get_main_queue(), ^{ + [_authContext acquireTokenWithResource:scopes[0] + clientId:_clientId + redirectUri:_redirectUri + completionBlock:weakAdalCallback]; + }); + return; + } + + error = [NSError errorWithDomain:@"AADAccountProvider" code:0 userInfo:@{ @"Reason" : @"Unknown ADAL error." }]; + break; + } + } + + dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0), ^{ completionBlock(result, error); }); + }; + + weakAdalCallback = adalCallback; + [_authContext acquireTokenSilentWithResource:scopes[0] + clientId:_clientId + redirectUri:_redirectUri + userId:_tokenCacheItem.userInformation.userId + completionBlock:adalCallback]; + } +} + +- (NSArray*)getUserAccounts +{ + @synchronized(self) + { + return _tokenCacheItem ? + @[ [[MCDUserAccount alloc] initWithAccountId:_tokenCacheItem.userInformation.uniqueId type:MCDUserAccountTypeAAD] ] : + nil; + } +} + +- (void)onAccessTokenError:(NSString*)accountId scopes:(NSArray*)scopes isPermanentError:(BOOL)isPermanentError +{ + @synchronized(self) + { + if ([accountId isEqualToString:_tokenCacheItem.userInformation.uniqueId]) + { + if (isPermanentError) + { + _tokenCacheItem = nil; + [self _raiseAccountChangedEvent]; + } + else + { + // If not a permanent error, try just refreshing the token by calling ADAL's acquireToken: again + [_authContext acquireTokenWithResource:scopes[0] + clientId:_clientId + redirectUri:_redirectUri + completionBlock:^(__unused ADAuthenticationResult* result){}]; + } + } + else + { + NSLog(@"accountId was not found in AADAccountProvider."); + } + } +} + +@end diff --git a/iOS/samples/GraphNotificationsSample/SampleAccountProviders/AADMSAAccountProvider.h b/iOS/samples/GraphNotificationsSample/SampleAccountProviders/AADMSAAccountProvider.h new file mode 100644 index 0000000..da8fe0b --- /dev/null +++ b/iOS/samples/GraphNotificationsSample/SampleAccountProviders/AADMSAAccountProvider.h @@ -0,0 +1,44 @@ +// +// Copyright (c) Microsoft Corporation. All rights reserved. +// + +#pragma once + +#import +#import +#import + +#import "SampleAccountActionFailureReason.h" + +typedef NS_ENUM(NSInteger, AADMSAAccountProviderSignInState) +{ + AADMSAAccountProviderSignInStateSignedOut, + AADMSAAccountProviderSignInStateSignedInMSA, + AADMSAAccountProviderSignInStateSignedInAAD, +}; + +// @brief A sample MCDUserAccountProvider that wraps around an AAD provider and an MSA provider. +// Supports only a single user account at a time - trying to log into more than one account at once will throw an exception. +// Any accounts logged into will be made available through the MCDUserAccountProvider interface. +// +// When signed into an AAD account, because of AAD limitations, +// only the first scope in scopes[] passed to for getAccessTokenForUserAccountIdAsync: and onAccessTokenError:, is used +// +// msaClientId is a guid from the app's registration in the msa apps portal +// msaScopeOverrides is a map for the app to specify special scopes to replace the default ones +// aadApplicationId is a guid from the app's registration in the azure portal +// aadRedirectUri is a Uri specified in the azure portal +@interface AADMSAAccountProvider : NSObject +@property(readonly, atomic) AADMSAAccountProviderSignInState signInState; +@property(readonly, nonatomic, copy, nonnull) NSString* msaClientId; +@property(readonly, nonatomic, copy, nonnull) NSString* aadApplicationId; + +- (nullable instancetype)initWithMsaClientId:(nonnull NSString*)msaClientId + msaScopeOverrides:(nullable NSDictionary*>*) scopes + aadApplicationId:(nonnull NSString*)aadApplicationId + aadRedirectUri:(nonnull NSURL*)aadRedirectUri; +- (void)signInMSAWithCompletionCallback:(nonnull SampleAccountProviderCompletionBlock)callback; +- (void)signInAADWithCompletionCallback:(nonnull SampleAccountProviderCompletionBlock)callback; +- (void)signOutWithCompletionCallback:(nonnull SampleAccountProviderCompletionBlock)callback; + +@end diff --git a/iOS/samples/GraphNotificationsSample/SampleAccountProviders/AADMSAAccountProvider.m b/iOS/samples/GraphNotificationsSample/SampleAccountProviders/AADMSAAccountProvider.m new file mode 100644 index 0000000..8c94c26 --- /dev/null +++ b/iOS/samples/GraphNotificationsSample/SampleAccountProviders/AADMSAAccountProvider.m @@ -0,0 +1,135 @@ +// +// Copyright (c) Microsoft Corporation. All rights reserved. +// + +#import "AADMSAAccountProvider.h" + +#import "AADAccountProvider.h" +#import "MSAAccountProvider.h" + +static NSString* const AADMSAAccountProviderExceptionName = @"AADMSAAccountProviderException"; + +@interface AADMSAAccountProvider () +@property(readonly, nonatomic, strong) MSAAccountProvider* msaProvider; +@property(readonly, nonatomic, strong) AADAccountProvider* aadProvider; +@end + +@implementation AADMSAAccountProvider + +@synthesize userAccountChanged = _userAccountChanged; + +- (instancetype)initWithMsaClientId:(NSString*)msaClientId + msaScopeOverrides:(NSDictionary*>*)scopes + aadApplicationId:(NSString*)aadApplicationId + aadRedirectUri:(NSURL*)aadRedirectUri +{ + if (self = [super init]) + { + _userAccountChanged = [MCDUserAccountChangedEvent new]; + _msaProvider = [[MSAAccountProvider alloc] initWithClientId:msaClientId scopeOverrides:scopes]; + _aadProvider = [[AADAccountProvider alloc] initWithClientId:aadApplicationId redirectUri:aadRedirectUri]; + + if (_msaProvider.signedIn && _aadProvider.signedIn) + { + // Shouldn't ever happen, but if it does, sign out of AAD + [_aadProvider signOutWithCompletionCallback:^(__unused BOOL success, __unused SampleAccountActionFailureReason reason){}]; + } + + [_msaProvider.userAccountChanged subscribe:^void() { [self.userAccountChanged raise]; }]; + [_aadProvider.userAccountChanged subscribe:^void() { [self.userAccountChanged raise]; }]; + } + return self; +} + +- (AADMSAAccountProviderSignInState)signInState +{ + @synchronized(self) + { + if (_msaProvider.signedIn) + { + return AADMSAAccountProviderSignInStateSignedInMSA; + } + else if (_aadProvider.signedIn) + { + return AADMSAAccountProviderSignInStateSignedInAAD; + } + return AADMSAAccountProviderSignInStateSignedOut; + } +} + +- (NSString*)msaClientId +{ + return _msaProvider.clientId; +} + +- (NSString*)aadApplicationId +{ + return _aadProvider.clientId; +} + +- (id)_signedInProvider +{ + switch (self.signInState) + { + case AADMSAAccountProviderSignInStateSignedInMSA: return _msaProvider; + case AADMSAAccountProviderSignInStateSignedInAAD: return _aadProvider; + default: return nil; + } +} + +- (void)signInMSAWithCompletionCallback:(SampleAccountProviderCompletionBlock)callback +{ + if (self.signInState != AADMSAAccountProviderSignInStateSignedOut) + { + [NSException raise:AADMSAAccountProviderExceptionName format:@"Already signed into an account!"]; + } + [_msaProvider signInWithCompletionCallback:callback]; +} + +- (void)signInAADWithCompletionCallback:(SampleAccountProviderCompletionBlock)callback +{ + if (self.signInState != AADMSAAccountProviderSignInStateSignedOut) + { + [NSException raise:AADMSAAccountProviderExceptionName format:@"Already signed into an account!"]; + } + [_aadProvider signInWithCompletionCallback:callback]; +} + +- (void)signOutWithCompletionCallback:(__unused SampleAccountProviderCompletionBlock)callback +{ + id signedInProvider = [self _signedInProvider]; + if (!signedInProvider) + { + [NSException raise:AADMSAAccountProviderExceptionName format:@"Not signed into an account!"]; + } + [signedInProvider signOutWithCompletionCallback:callback]; +} + +- (void)getAccessTokenForUserAccountIdAsync:(NSString*)accountId + scopes:(NSArray*)scopes + completion:(void (^)(MCDAccessTokenResult*, NSError*))completionBlock +{ + id signedInProvider = [self _signedInProvider]; + if (!signedInProvider) + { + [NSException raise:AADMSAAccountProviderExceptionName format:@"Not signed into an account!"]; + } + [signedInProvider getAccessTokenForUserAccountIdAsync:accountId scopes:scopes completion:completionBlock]; +} + +- (NSArray*)getUserAccounts +{ + return [[self _signedInProvider] getUserAccounts]; +} + +- (void)onAccessTokenError:(NSString*)accountId scopes:(NSArray*)scopes isPermanentError:(BOOL)isPermanentError +{ + id signedInProvider = [self _signedInProvider]; + if (!signedInProvider) + { + [NSException raise:AADMSAAccountProviderExceptionName format:@"Not signed into an account!"]; + } + [signedInProvider onAccessTokenError:accountId scopes:scopes isPermanentError:isPermanentError]; +} + +@end diff --git a/iOS/samples/GraphNotificationsSample/SampleAccountProviders/MSAAccountProvider.h b/iOS/samples/GraphNotificationsSample/SampleAccountProviders/MSAAccountProvider.h new file mode 100644 index 0000000..e4d9380 --- /dev/null +++ b/iOS/samples/GraphNotificationsSample/SampleAccountProviders/MSAAccountProvider.h @@ -0,0 +1,28 @@ +// +// Copyright (c) Microsoft Corporation. All rights reserved. +// + +#pragma once + +#import +#import +#import + +#import "SingleUserAccountProvider.h" + +/** + * @brief + * Sample implementation of MCDUserAccountProvider. + * Exposes a single MSA account, that the user logs into via UIWebView, to CDP. + * Follows OAuth2.0 protocol, but automatically refreshes tokens when they are close to expiring. + */ +@interface MSAAccountProvider : NSObject + +// @brief clientId is a guid from the app's registration in the msa portal +// scopeOverrides is a map for the app to specify special scopes to replace the default ones +- (nullable instancetype)initWithClientId:(nonnull NSString*)clientId + scopeOverrides:(nullable NSDictionary*>*)scopes; + +@property(readonly, nonatomic, copy, nonnull) NSString* clientId; + +@end diff --git a/iOS/samples/GraphNotificationsSample/SampleAccountProviders/MSAAccountProvider.m b/iOS/samples/GraphNotificationsSample/SampleAccountProviders/MSAAccountProvider.m new file mode 100644 index 0000000..fe42be0 --- /dev/null +++ b/iOS/samples/GraphNotificationsSample/SampleAccountProviders/MSAAccountProvider.m @@ -0,0 +1,492 @@ +// +// Copyright (c) Microsoft Corporation. All rights reserved. +// + +#import "MSAAccountProvider.h" +#import "MSATokenCache.h" +#import "MSATokenRequest.h" + +/** + * Terms: + * - Scope: OAuth feature, limits what a token actually gives permissions to. + * https://www.oauth.com/oauth2-servers/scope/ + * - Access token: A standard JSON web token for a given scope. + * This is the actual token/user ticket used to authenticate with CDP services. + * https://oauth.net/2/ + * https://www.oauth.com/oauth2-servers/access-tokens/ + * - Refresh token: A standard OAuth refresh token. + * Lasts longer than access tokens, and is used to request new access tokens/refresh access tokens when they expire. + * This library caches one refresh token per user. + * As such, the refresh token must already be authorized/consented to for all CDP scopes that will be used in the app. + * https://oauth.net/2/grant-types/refresh-token/ + * - Grant type: Type of OAuth authorization request to make (ie: token, password, auth code) + * https://oauth.net/2/grant-types/ + * - Auth code: OAuth auth code, can be exchanged for a token. + * This library has the user sign in interactively for the auth code grant type, + * then retrieves the auth code from the return URL. + * https://oauth.net/2/grant-types/authorization-code/ + * - Client ID: ID of an app's registration in the MSA portal. As of the time of writing, the portal uses GUIDs. + * + * The flow of this library is described below: + * Signing in + * 1. signInWithCompletionCallback: is called + * 2. UIWebView is presented to the user for sign in + * 3. Use authcode returned from user's sign in to fetch refresh token + * 4. Refresh token is cached - if the user does not sign out, but the app is restarted, + * the user will not need to enter their credentials/consent again when signInWithCompletionCallback: is called. + * 4. Now treated as signed in. Account is exposed to CDP. userAccountChanged event is fired. + * + * While signed in + * CDP asks for access tokens + * 1. Check if access token is in cache + * 2. If not in cache, request a new access token using the cached refresh token. + * 3. If in cache but close to expiry, the access token is refreshed using the refresh token. + * The refreshed access token is returned. + * 4. If in cache and not close to expiry, just return it. + * + * Signing out + * 1. signOutWithCompletionCallback: is called + * 2. UIWebView is quickly popped up to go through the sign out URL + * 3. Cache is cleared. + * 4. Now treated as signed out. Account is no longer exposed to CDP. userAccountChanged event is fired. + */ + +#pragma mark - Constants +// CDP's SDK currently requires authorization for all features, otherwise platform initialization will fail. +// As such, the user must sign in/consent for the following scopes. This may change to become more modular in the future. +static NSString* const MsaRequiredScopes = // + @"wl.offline_access+" // read and update user info at any time + @"ccs.ReadWrite+" // device commanding scope + @"dds.read+" // device discovery scope (discover other devices) + @"dds.register+" // device discovery scope (allow discovering this device) + @"wns.connect+" // push notification scope + @"asimovrome.telemetry+" // asimov token scope + @"https://activity.windows.com/UserActivity.ReadWrite.CreatedByApp"; // default useractivities scope + +// OAuth URLs +static NSString* const MsaRedirectUrl = @"https://login.live.com/oauth20_desktop.srf"; +static NSString* const MsaAuthorizeUrl = @"https://login.live.com/oauth20_authorize.srf"; +static NSString* const MsaLogoutUrl = @"https://login.live.com/oauth20_logout.srf"; + +// NSError constants +static NSString* const MsaAccountProviderErrorDomain = @"MSAAccountProvider"; +static const NSInteger MsaAccountProviderErrorInvalidAccountId = 100; +static const NSInteger MsaAccountProviderErrorAccessTokenTemporaryError = 101; +static const NSInteger MsaAccountProviderErrorAccessTokenPermanentError = 102; + +#pragma mark - Static Helpers +// Helper function - gets the NSURLQueryItem matching name +static NSURLQueryItem* GetQueryItemForName(NSArray* queryItems, NSString* name) +{ + NSUInteger index = [queryItems indexOfObjectPassingTest:^BOOL(NSURLQueryItem* queryItem, __unused NSUInteger idx, __unused BOOL* stop) { + return [queryItem.name isEqualToString:name]; + }]; + return (index != NSNotFound) ? queryItems[index] : nil; +} + +#pragma mark - Private Members +@interface MSAAccountProvider () +{ + NSString* _clientId; + NSDictionary*>* _scopeOverrides; + MCDUserAccount* _account; + MSATokenCache* _tokenCache; + BOOL _signInSignOutInProgress; + SampleAccountProviderCompletionBlock _signInSignOutCallback; + UIWebView* _webView; +} +@end + +#pragma mark - Implementation +@implementation MSAAccountProvider +@synthesize userAccountChanged = _userAccountChanged; + +- (instancetype)initWithClientId:(NSString*)clientId + scopeOverrides:(NSDictionary*>*)scopes +{ + NSLog(@"MSAAccountProvider initWithClientId"); + + if (self = [super init]) + { + _clientId = [clientId copy]; + _scopeOverrides = [scopes copy]; + + _tokenCache = [MSATokenCache cacheWithClientId:_clientId delegate:self]; + + _userAccountChanged = [MCDUserAccountChangedEvent new]; + _signInSignOutInProgress = NO; + _signInSignOutCallback = nil; + + if ([_tokenCache loadSavedRefreshToken]) + { + NSLog(@"Loaded previous session for MSAAccountProvider. Starting as signed in."); + _account = [[MCDUserAccount alloc] initWithAccountId:[[NSUUID UUID] UUIDString] type:MCDUserAccountTypeMSA]; + } + else + { + NSLog(@"No previous session could be loaded for MSAAccountProvider. Starting as signed out."); + } + } + + return self; +} + +#pragma mark - Private Helpers +- (NSString*)_getAuthScopes: (NSArray*) incoming +{ + NSMutableArray* scopes = [NSMutableArray new]; + for (NSString* scope in incoming) + { + NSArray* replacements = [_scopeOverrides objectForKey:scope]; + if (replacements) + { + [scopes addObjectsFromArray:replacements]; + } + else + { + [scopes addObject:scope]; + } + } + return [scopes componentsJoinedByString:@"+"]; +} + +- (void)_raiseAccountChangedEvent +{ + NSLog(@"Raise Account changed event"); + dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0), ^{ + // fire event on a different thread + [self.userAccountChanged raise]; + }); +} + +- (void)_addAccount +{ + @synchronized(self) + { + NSLog(@"Adding an account."); + _account = [[MCDUserAccount alloc] initWithAccountId:[[NSUUID UUID] UUIDString] type:MCDUserAccountTypeMSA]; + [self _raiseAccountChangedEvent]; + } +} + +- (void)_removeAccount +{ + @synchronized(self) + { + // clean account states + if (self.signedIn) + { + NSLog(@"Removing account."); + _account = nil; + [_tokenCache clearTokens]; + [self _raiseAccountChangedEvent]; + } + } +} + +- (void)_loadWebRequest:(NSString*)requestUri +{ + @synchronized(self) + { + UIViewController* rootVC = [[[[UIApplication sharedApplication] delegate] window] rootViewController]; + + // lazy init + if (!_webView) + { + _webView = [[UIWebView alloc] initWithFrame:rootVC.view.bounds]; + _webView.delegate = self; + } + + [rootVC.view addSubview:_webView]; + + NSURLRequest* urlRequest = [NSURLRequest requestWithURL:[NSURL URLWithString:requestUri]]; + [_webView loadRequest:urlRequest]; + } +} + +- (void)_signInSignOutSucceededAsync:(BOOL)successful reason:(SampleAccountActionFailureReason)reason +{ + dispatch_async(dispatch_get_main_queue(), ^{ [_webView removeFromSuperview]; }); + dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0), ^{ + _signInSignOutCallback(successful, reason); + _signInSignOutCallback = nil; + _signInSignOutInProgress = NO; + }); +} + +/** + * Asynchronously requests a new access token for the provided scope(s) and caches it. + * This assumes that the sign in helper is currently signed in. + */ +- (void)_requestNewAccessTokenAsync:(NSString*)scope callback:(void (^)(MCDAccessTokenResult*, NSError*))completionBlock +{ + // Need the refresh token first, then can use it to request an access token + [_tokenCache getRefreshTokenAsync:^void(NSString* refreshToken) { + NSLog(@"Fetching access token for scope:%@", scope); + [MSATokenRequest + doAsyncRequestWithClientId:_clientId + grantType:MsaTokenRequestGrantTypeRefresh + scope:scope + redirectUri:nil + token:refreshToken + callback:^void(MSATokenRequestResult* result) { + switch (result.status) + { + case MSATokenRequestStatusSuccess: + { + NSLog(@"Successfully fetched access token."); + [_tokenCache setAccessToken:result.accessToken forScope:scope expiresIn:result.expiresIn]; + + completionBlock([[MCDAccessTokenResult alloc] initWithAccessToken:result.accessToken + status:MCDAccessTokenRequestStatusSuccess], + nil); + break; + } + case MSATokenRequestStatusTransientFailure: + { + NSLog(@"Requesting new access token failed temporarily, please try again."); + completionBlock(nil, [NSError errorWithDomain:MsaAccountProviderErrorDomain + code:MsaAccountProviderErrorAccessTokenTemporaryError + userInfo:nil]); + break; + } + default: // PermanentFailure + { + NSLog(@"Permanent error occurred while fetching access token."); + [self onAccessTokenError:_account.accountId scopes:@[ scope ] isPermanentError:YES]; + completionBlock(nil, [NSError errorWithDomain:MsaAccountProviderErrorDomain + code:MsaAccountProviderErrorAccessTokenPermanentError + userInfo:nil]); + break; + } + } + }]; + }]; +} + +#pragma mark - Interactive Sign In/Out +- (BOOL)signedIn +{ + @synchronized(self) + { + return _account != nil; + } +} + +/** + * Pops up a webview for the user to sign in with their MSA, then uses the auth code returned to cache a refresh token for the user. + * If a refresh token was already cached from a previous session, it will be used instead, and no webview will be displayed. + */ +- (void)signInWithCompletionCallback:(SampleAccountProviderCompletionBlock)signInCallback +{ + @synchronized(self) + { + _signInSignOutCallback = signInCallback; + + if (self.signedIn || _signInSignOutInProgress) + { + // if already signed in or in the process, callback immediately with failure and reason + [self _signInSignOutSucceededAsync:NO + reason:(self.signedIn ? SampleAccountActionFailureReasonAlreadySignedIn : + SampleAccountActionFailureReasonSigninSignOutInProgress)]; + return; + } + + _signInSignOutInProgress = YES; + + // issue request to sign in + NSArray* scopes = [MsaRequiredScopes componentsSeparatedByString:@"+"]; + [self _loadWebRequest:[NSString stringWithFormat:@"%@?redirect_uri=%@&response_type=code&client_id=%@&scope=%@", MsaAuthorizeUrl, + MsaRedirectUrl, _clientId, [self _getAuthScopes:scopes]]]; + } +} + +/** + * Signs the user out by going through the webview, then clears the cache and current state. + */ +- (void)signOutWithCompletionCallback:(SampleAccountProviderCompletionBlock)signOutCallback +{ + @synchronized(self) + { + _signInSignOutCallback = signOutCallback; + + if (!self.signedIn || _signInSignOutInProgress) + { + // if already signed out or in the process, callback immediately with failure and reason + [self _signInSignOutSucceededAsync:NO + reason:(self.signedIn ? SampleAccountActionFailureReasonSigninSignOutInProgress : + SampleAccountActionFailureReasonAlreadySignedOut)]; + return; + } + + _signInSignOutInProgress = YES; + + // issue request to sign out + [self _loadWebRequest:[NSString stringWithFormat:@"%@?client_id=%@&redirect_uri=%@", MsaLogoutUrl, _clientId, MsaRedirectUrl]]; + } +} + +/** + * Continuation for signIn/signOut after the webview completes. + */ +- (void)webViewDidFinishLoad:(UIWebView*)webView +{ + @synchronized(self) + { + // Validate the URL + NSURLComponents* tokenURLComponents = [NSURLComponents componentsWithURL:webView.request.URL resolvingAgainstBaseURL:nil]; + + if (![tokenURLComponents.path containsString:@"oauth20_desktop.srf"]) + { + // finishing off loading intermediate pages, + // e.g., input username/password page, consent interrupt page, wrong username/password page etc. + // no need to handle them, return early. + return; + } + + NSArray* tokenURLQueryItems = tokenURLComponents.queryItems; + + if (GetQueryItemForName(tokenURLQueryItems, @"error")) + { + // sign in or sign out ending in failure + [self _signInSignOutSucceededAsync:NO reason:SampleAccountActionFailureReasonUnknown]; + return; + } + + NSString* authCode = GetQueryItemForName(tokenURLQueryItems, @"code").value; + if (!authCode) + { + // sign out ended in success + [self _removeAccount]; + [self _signInSignOutSucceededAsync:YES reason:SampleAccountActionNoFailure]; + } + else + { + // sign in ended in success + if (authCode.length <= 0) + { + // very unusual + [self _signInSignOutSucceededAsync:NO reason:SampleAccountActionFailureReasonFailToRetrieveAuthCode]; + return; + } + + // Fetch a refresh token using the auth code + void (^requestRefreshTokenTokenCallback)(MSATokenRequestResult*) = ^void(MSATokenRequestResult* result) { + if (result.status == MSATokenRequestStatusSuccess) + { + NSString* newRefreshToken = result.refreshToken; + NSAssert(newRefreshToken != nil, @"refresh token can not be null when refreshing refresh token succeeded"); + + NSLog(@"Successfully fetch the root refresh token."); + [_tokenCache setRefreshToken:newRefreshToken]; + [self _addAccount]; + [self _signInSignOutSucceededAsync:YES reason:SampleAccountActionNoFailure]; + } + else + { + NSLog(@"Failed to fetch root refresh token using authcode."); + [self _signInSignOutSucceededAsync:NO reason:SampleAccountActionFailureReasonFailToRetrieveRefreshToken]; + } + }; + + dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0), ^{ + NSLog(@"Fetch root refresh token using authcode."); + [MSATokenRequest doAsyncRequestWithClientId:_clientId + grantType:MsaTokenRequestGrantTypeCode + scope:nil + redirectUri:MsaRedirectUrl + token:authCode + callback:requestRefreshTokenTokenCallback]; + }); + } + } +} + +/** + * Continuation for signIn/signOut after the webview completes with a failure. + */ +- (void)webView:(UIWebView*)__unused webView didFailLoadWithError:(NSError*)error +{ + @synchronized(self) + { + // This gets invoked when we interrupt/cancel because we saw the oauth complete page. + int WebKitErrorFrameLoadInterruptedByPolicyChange = 102; + if (error.code != WebKitErrorFrameLoadInterruptedByPolicyChange /*interrupted*/ + && error.code != NSURLErrorCancelled) + { + [self _signInSignOutSucceededAsync:NO reason:SampleAccountActionFailureReasonUserCancelled]; + } + } +} + +#pragma mark - MCDUserAccountProvider Overrides +- (void)getAccessTokenForUserAccountIdAsync:(NSString*)accountId + scopes:(NSArray*)scopes + completion:(void (^)(MCDAccessTokenResult*, NSError*))completionBlock +{ + if (![accountId isEqualToString:_account.accountId]) + { + NSLog(@"accountId did not match logged in account - is the user signed in?"); + completionBlock( + nil, [NSError errorWithDomain:MsaAccountProviderErrorDomain code:MsaAccountProviderErrorInvalidAccountId userInfo:nil]); + return; + } + + dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0), ^{ + @synchronized(self) + { + // check if access token cache already has a valid token + NSString* accessTokenScope = [self _getAuthScopes:scopes]; + + // clang-format off + [_tokenCache getAccessTokenForScopeAsync:accessTokenScope callback:^void(NSString* accessToken) + { + if (accessToken.length > 0) + { + NSLog(@"Found valid access token for scope %@ in cache, return early", accessTokenScope); + completionBlock( + [[MCDAccessTokenResult alloc] initWithAccessToken:accessToken status:MCDAccessTokenRequestStatusSuccess], nil); + return; + } + + NSLog(@"Didn't find valid access token for scope %@ in cache, try to fetch it", accessTokenScope); + [self _requestNewAccessTokenAsync:accessTokenScope callback:completionBlock]; + }]; + // clang-format on + } + }); +} + +- (NSArray*)getUserAccounts +{ + @synchronized(self) + { + return _account ? @[ _account ] : nil; + } +} + +- (void)onAccessTokenError:(NSString*)__unused accountId scopes:(NSArray*)__unused scopes isPermanentError:(BOOL)isPermanentError +{ + @synchronized(self) + { + if (isPermanentError) + { + [self _removeAccount]; + } + else + { + [_tokenCache markAllTokensExpired]; + } + } +} + +#pragma mark - MSATokenCache Delegate +- (void)onTokenCachePermanentFailure +{ + if (_account) + { + [self onAccessTokenError:_account.accountId scopes:[_tokenCache allScopes] isPermanentError:YES]; + } +} + +@end diff --git a/iOS/samples/GraphNotificationsSample/SampleAccountProviders/MSATokenCache.h b/iOS/samples/GraphNotificationsSample/SampleAccountProviders/MSATokenCache.h new file mode 100644 index 0000000..9595a22 --- /dev/null +++ b/iOS/samples/GraphNotificationsSample/SampleAccountProviders/MSATokenCache.h @@ -0,0 +1,42 @@ +// +// Copyright (c) Microsoft Corporation. All rights reserved. +// + +#pragma once + +#import + +// @brief Receives callback from the cache for any permanent failures +@protocol MSATokenCacheDelegate +- (void)onTokenCachePermanentFailure; +@end + +// @brief Interface for caching and automatically refreshing MSA refresh and access tokens. +// Refresh tokens are automatically saved to disk. +// On permanent failure (cannot retry), a callback is sent to the delegate. +// These interfaces currently only support one user. forUser: will be added after platform support for multi-user is enabled. +@interface MSATokenCache : NSObject ++ (nullable instancetype)cacheWithClientId:(nonnull NSString*)clientId delegate:(nullable id)delegate; +- (nullable instancetype)initWithClientId:(nonnull NSString*)clientId delegate:(nullable id)delegate; + +// @brief Adds/gets tokens to/from the cache, automatically refreshing them once expired. +- (void)setRefreshToken:(nonnull NSString*)refreshToken; +- (void)setAccessToken:(nonnull NSString*)accessToken forScope:(nonnull NSString*)scope expiresIn:(NSTimeInterval)expiry; +- (void)getRefreshTokenAsync:(nonnull void (^)(NSString* _Nullable accessToken))callback; +- (void)getAccessTokenForScopeAsync:(nonnull NSString*)scope callback:(nonnull void (^)(NSString* _Nullable accessToken))callback; + +// @brief Returns the scopes for which there are currently access tokens cached. +- (nonnull NSArray*)allScopes; + +// @brief Attempts to load a refresh token that was previously saved, and returns the success value of the operation. +// If successful, the loaded refresh token can be retrieved from getRefreshTokenAsync: +- (BOOL)loadSavedRefreshToken; + +// @brief Clears the cache, including the saved refresh token. +- (void)clearTokens; + +// @brief Marks all tokens as expired, such that a refresh will be attempted before the next time any token is returned. +- (void)markAllTokensExpired; + +@property(nonatomic, readwrite, nullable, strong) id delegate; +@end \ No newline at end of file diff --git a/iOS/samples/GraphNotificationsSample/SampleAccountProviders/MSATokenCache.m b/iOS/samples/GraphNotificationsSample/SampleAccountProviders/MSATokenCache.m new file mode 100644 index 0000000..205705a --- /dev/null +++ b/iOS/samples/GraphNotificationsSample/SampleAccountProviders/MSATokenCache.m @@ -0,0 +1,500 @@ +// +// Copyright (c) Microsoft Corporation. All rights reserved. +// + +#import "MSATokenCache.h" +#import "MSATokenRequest.h" + +static NSString* const MsaOfflineAccessScope = @"wl.offline_access"; + +static NSString* const JsonTokenKey = @"refresh_token"; +static NSString* const JsonExpirationKey = @"expires"; + +// Max number of times to try to refresh a token through transient failures +static const NSUInteger MsaTokenRefreshMaxRetries = 3; + +// How quickly to retry refresh token refreshes on transient failure; 30 minutes. +static const int64_t MsaRefreshTokenRetryInterval = 30 * 60; + +// How quickly to retry access token refreshes on transient failure; 3 minutes. +static const int64_t MsaAccessTokenRetryInterval = 3 * 60; + +// How long a refresh token is expected to last without expiring; 10 days. +static const NSTimeInterval MsaRefreshTokenExpirationInterval = 10 * 24 * 60 * 60; + +// Time from expiration at which a refresh token is considered 'close to expiring'; 7 days. +// (This value is intended to be aggressive and keep the refresh token relatively far from expiry) +static const NSTimeInterval MsaRefreshTokenCloseToExpiryInterval = 7 * 24 * 60 * 60; + +// Time from expiration at which an access token is considered 'close to expiring'; 5 minutes. +static const NSTimeInterval MsaAccessTokenCloseToExpiryInterval = 5 * 60; + +// @brief Private helper class, encapsulates a single MSA token to be cached, and how to refresh it. +@interface MSATokenCacheItem : NSObject ++ (nullable instancetype)cacheItemWithToken:(nonnull NSString*)token + expiresIn:(NSTimeInterval)expiry + refreshWith:(nonnull MSATokenRequest*)refreshRequest + parent:(nonnull MSATokenCache*)parent; +- (nullable instancetype)initWithToken:(nonnull NSString*)token + expiresIn:(NSTimeInterval)expiry + refreshWith:(nonnull MSATokenRequest*)refreshRequest + parent:(nonnull MSATokenCache*)parent; + +// Asynchronously fetches the token held by this item, refreshing it if necessary. +- (void)getTokenAsync:(nonnull void (^)(NSString* _Nullable token))callback; + +@property(readwrite, nonnull, nonatomic, copy) NSString* token; +@property(readwrite, nonnull, nonatomic, strong) NSDate* expirationDate; +@property(readwrite, nonnull, nonatomic, strong) MSATokenRequest* refreshRequest; +@property(readwrite, nonnull, nonatomic, strong) MSATokenCache* parent; +@property(readonly, nonatomic) NSTimeInterval closeToExpiryInterval; +@property(readonly, nonatomic) int64_t retryInterval; + +// Private helper for refreshing this token. Only to be used by this class and its subclass. +// Returns the refresh token needed to refresh the token held by this item. +// For access tokens, this gets the refresh token held by the cache. +// For refresh tokens, just return the currently-held token. +- (void)getRefreshTokenAsync:(nonnull void (^)(NSString* _Nullable token))callback; + +// Private helper for refreshing this token. Only to be used by this class and its subclass. +// For access tokens, sets the new token and expiration. +// For refresh tokens, marks current access tokens as expired, and caches the refresh token in persistent storage. +- (void)onSuccessfulRefresh:(nonnull MSATokenRequestResult*)result; + +@end + +// @brief Subclass of MSATokenCacheItem for refresh tokens +@interface MSARefreshTokenCacheItem : MSATokenCacheItem ++ (nullable instancetype)loadSavedRefreshTokenWithParent:(nonnull MSATokenCache*)parent; +- (void)saveRefreshToken; +@end + +// MSATokenCache privates +@interface MSATokenCache () +@property(readonly, nonnull, nonatomic, copy) NSString* clientId; +@property(readonly, nonnull, nonatomic, strong) NSMutableDictionary* cachedAccessTokens; // keyed on scopes +@property(readwrite, nullable, nonatomic, strong) MSARefreshTokenCacheItem* cachedRefreshToken; +- (void)markAccessTokensExpired; +@end + +@implementation MSATokenCacheItem + ++ (instancetype)cacheItemWithToken:(NSString*)token + expiresIn:(NSTimeInterval)expiry + refreshWith:(MSATokenRequest*)refreshRequest + parent:(MSATokenCache*)parent +{ + return [[self alloc] initWithToken:token expiresIn:expiry refreshWith:refreshRequest parent:parent]; +} + +- (instancetype)initWithToken:(NSString*)token + expiresIn:(NSTimeInterval)expiry + refreshWith:(MSATokenRequest*)refreshRequest + parent:(MSATokenCache*)parent +{ + if (self = [super init]) + { + _token = [token copy]; + _expirationDate = [NSDate dateWithTimeIntervalSinceNow:expiry]; + _refreshRequest = refreshRequest; + _parent = parent; + } + return self; +} + +- (void)getTokenAsync:(void (^)(NSString*))callback +{ + [self getTokenAsync:callback maxRetries:MsaTokenRefreshMaxRetries]; +} + +- (void)getTokenAsync:(void (^)(NSString*))callback maxRetries:(NSUInteger)maxRetries +{ + if ([_expirationDate timeIntervalSinceNow] >= self.closeToExpiryInterval) + { + // If expiration date is sufficiently far away + callback(self.token); + } + else + { + // If expired or close to it, get the refresh token and attempt to refresh with it + [self getRefreshTokenAsync:^void(NSString* refreshToken) { + if (!refreshToken) + { + // Unable to get the refresh token even after retrying + // Consider as a permanent failure and call back with no tokens + NSLog(@"Unable to get refresh token. Cancelling refresh and removing all tokens from cache."); + [_parent clearTokens]; + callback(nil); + } + + NSLog(@"Refreshing token..."); + [_refreshRequest + requestAsyncWithToken:refreshToken + callback:^void(MSATokenRequestResult* result) { + switch (result.status) + { + case MSATokenRequestStatusSuccess: + { + [self onSuccessfulRefresh:result]; + callback(self.token); + break; + } + case MSATokenRequestStatusTransientFailure: + { + if (maxRetries > 0) + { + // Retry the refresh + NSLog(@"Encountered transient error when refreshing token, retrying in %lld seconds...", + self.retryInterval); + dispatch_time_t retryTime = dispatch_time(DISPATCH_TIME_NOW, self.retryInterval * NSEC_PER_SEC); + dispatch_after(retryTime, dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0), + ^{ [self getTokenAsync:callback maxRetries:(maxRetries - 1)]; }); + } + else + { + // Reached max number of retries + NSLog(@"Reached max number of retries for refreshing token."); + callback(nil); + } + break; + } + default: // PermanentFailure + { + NSLog(@"Permanent error occurred while refreshing token. Clearing the cache..."); + [_parent clearTokens]; + [_parent.delegate onTokenCachePermanentFailure]; + callback(nil); + break; + } + } + }]; + }]; + } +} + +- (NSTimeInterval)closeToExpiryInterval +{ + return MsaAccessTokenCloseToExpiryInterval; // Base class expects access tokens +} + +- (int64_t)retryInterval +{ + return MsaAccessTokenRetryInterval; // Base class expects access tokens +} + +- (void)getRefreshTokenAsync:(void (^)(NSString*))callback +{ + [_parent getRefreshTokenAsync:callback]; // Base class expects access tokens, grab the refresh token from the parent +} + +- (void)onSuccessfulRefresh:(MSATokenRequestResult*)result +{ + @synchronized(self) + { + NSString* newToken = result.accessToken; + NSAssert(newToken.length > 0, @"UNEXPECTED: Refresh access token succeeded but access token was empty."); + + NSLog(@"Successfully refreshed access token."); + self.token = newToken; + self.expirationDate = [NSDate dateWithTimeIntervalSinceNow:result.expiresIn]; + } +} + +@end + +@implementation MSARefreshTokenCacheItem + ++ (instancetype)loadSavedRefreshTokenWithParent:(MSATokenCache*)parent +{ + NSLog(@"Loading refresh token from keychain..."); + + // clang-format off + NSDictionary* keychainMatchQuery = @{ + (id) kSecClass : (id) kSecClassGenericPassword, + (id) kSecAttrGeneric : parent.clientId, + (id) kSecMatchLimit : (id) kSecMatchLimitOne, // Only match one keychain item + (id) kSecReturnData : @YES // Return the data itself rather than a ref + }; + // clang-format on + + CFTypeRef keychainItems = NULL; + OSStatus keychainStatus = SecItemCopyMatching((CFDictionaryRef)keychainMatchQuery, &keychainItems); + if (keychainStatus == errSecItemNotFound) + { + NSLog(@"No refresh token found in keychain."); + return nil; + } + else if (keychainStatus != errSecSuccess) + { + NSLog(@"Unable to load refresh token from keychain with OSStatus %d", (int)keychainStatus); + return nil; + } + + NSError* jsonError = nil; + CFDataRef tokenData = (CFDataRef)keychainItems; + id deserializedTokenData = [NSJSONSerialization JSONObjectWithData:(__bridge NSData*)tokenData options:0 error:&jsonError]; + if (jsonError) + { + NSLog(@"Encountered JSON error \'%@\' while trying to load refresh token from keychain.", jsonError); + return nil; + } + else if (![deserializedTokenData isKindOfClass:[NSDictionary class]]) + { + NSLog(@"Loaded refresh token data from keychain was in an unexpected format. Will not load."); + return nil; + } + + NSDictionary* tokenDict = (NSDictionary*)deserializedTokenData; + NSString* loadedRefreshToken = (NSString*)(tokenDict[JsonTokenKey]); + + NSDateFormatter* dateFormatter = [NSDateFormatter new]; + dateFormatter.dateStyle = NSDateFormatterFullStyle; + dateFormatter.timeStyle = NSDateFormatterFullStyle; + NSDate* loadedRefreshTokenExpiry = [dateFormatter dateFromString:(NSString*)(tokenDict[JsonExpirationKey])]; + if (!loadedRefreshToken || !loadedRefreshTokenExpiry) + { + NSLog(@"Loaded refresh token data from keychain was incomplete or corrupted."); + return nil; + } + + NSTimeInterval timeUntilExpiration = [loadedRefreshTokenExpiry timeIntervalSinceDate:[NSDate date]]; + MSATokenRequest* refreshRequest = [MSATokenRequest tokenRequestWithClientId:parent.clientId + grantType:MsaTokenRequestGrantTypeRefresh + scope:MsaOfflineAccessScope + redirectUri:nil]; + MSARefreshTokenCacheItem* ret = + [self cacheItemWithToken:loadedRefreshToken expiresIn:timeUntilExpiration refreshWith:refreshRequest parent:parent]; + + NSLog(@"Successfully loaded refresh token from keychain."); + return ret; +} + +- (void)saveRefreshToken +{ + NSLog(@"Saving refresh token to keychain..."); + + NSDateFormatter* dateFormatter = [NSDateFormatter new]; + dateFormatter.dateStyle = NSDateFormatterFullStyle; + dateFormatter.timeStyle = NSDateFormatterFullStyle; + NSDictionary* tokenDict = @{ JsonTokenKey : self.token, JsonExpirationKey : [dateFormatter stringFromDate:self.expirationDate] }; + + NSError* jsonError = nil; + NSData* tokenData = [NSJSONSerialization dataWithJSONObject:tokenDict options:0 error:&jsonError]; + if (jsonError) + { + NSLog(@"Encountered JSON error \'%@\' while trying to save refresh token to keychain. Will not save.", jsonError); + return; + } + + // clang-format off + NSDictionary* keychainSearchQuery = @{ + (id) kSecClass : (id) kSecClassGenericPassword, + (id) kSecAttrGeneric : self.parent.clientId + }; + // clang-format on + + OSStatus keychainStatus = SecItemUpdate((CFDictionaryRef)keychainSearchQuery, (CFDictionaryRef) @{ (id) kSecValueData : tokenData }); + if (keychainStatus == errSecItemNotFound) + { + // After a device restart, this keychain item is only accessible after the device is unlocked at least once. + // This keychain item is not migrated when restoring a backup from another device. + id accessAttribute = (id)kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly; + + NSMutableDictionary* keychainAddQuery = [keychainSearchQuery mutableCopy]; + [keychainAddQuery addEntriesFromDictionary:@{ (id) kSecAttrAccessible : accessAttribute, (id) kSecValueData : tokenData }]; + keychainStatus = SecItemAdd((CFDictionaryRef)keychainAddQuery, NULL); + } + + if (keychainStatus != errSecSuccess) + { + NSLog(@"Failed to save refresh token data to keychain with OSStatus %d.", (int)keychainStatus); + } + + NSLog(@"Successfully saved refresh token data to keychain."); +} + +- (NSTimeInterval)closeToExpiryInterval +{ + return MsaRefreshTokenCloseToExpiryInterval; +} + +- (int64_t)retryInterval +{ + return MsaRefreshTokenRetryInterval; +} + +- (void)getRefreshTokenAsync:(void (^)(NSString*))callback +{ + callback(self.token); // Since this cache item holds a refresh token, just return it +} + +- (void)onSuccessfulRefresh:(MSATokenRequestResult*)result +{ + @synchronized(self) + { + NSString* newToken = result.refreshToken; + NSAssert(newToken.length > 0, @"UNEXPECTED: Refresh refresh token succeeded but access token was empty."); + + NSLog(@"Successfully refreshed refresh token."); + self.token = newToken; + self.expirationDate = [NSDate dateWithTimeIntervalSinceNow:MsaRefreshTokenExpirationInterval]; + [self saveRefreshToken]; + [self.parent markAccessTokensExpired]; + } +} + +@end + +// MSATokenCache implementation +@implementation MSATokenCache + ++ (instancetype)cacheWithClientId:(NSString*)clientId delegate:(id)delegate +{ + return [[self alloc] initWithClientId:clientId delegate:delegate]; +} + +- (instancetype)initWithClientId:(NSString*)clientId delegate:(id)delegate +{ + if (self = [super init]) + { + _clientId = [clientId copy]; + _delegate = delegate; + _cachedAccessTokens = [NSMutableDictionary new]; + } + return self; +} + +- (void)setRefreshToken:(NSString*)refreshToken +{ + MSATokenRequest* refreshRequest = [MSATokenRequest tokenRequestWithClientId:_clientId + grantType:MsaTokenRequestGrantTypeRefresh + scope:MsaOfflineAccessScope + redirectUri:nil]; + @synchronized(self) + { + _cachedRefreshToken = [MSARefreshTokenCacheItem cacheItemWithToken:refreshToken + expiresIn:MsaRefreshTokenExpirationInterval + refreshWith:refreshRequest + parent:self]; + [_cachedRefreshToken saveRefreshToken]; + [self markAccessTokensExpired]; + } +} + +- (void)setAccessToken:(NSString*)accessToken forScope:(NSString*)scope expiresIn:(NSTimeInterval)expiry +{ + MSATokenRequest* refreshRequest = + [MSATokenRequest tokenRequestWithClientId:_clientId grantType:MsaTokenRequestGrantTypeRefresh scope:scope redirectUri:nil]; + + @synchronized(self) + { + [_cachedAccessTokens + setValue:[MSATokenCacheItem cacheItemWithToken:accessToken expiresIn:expiry refreshWith:refreshRequest parent:self] + forKey:scope]; + } +} + +- (void)getRefreshTokenAsync:(void (^)(NSString*))callback +{ + @synchronized(self) + { + if (_cachedRefreshToken) + { + [_cachedRefreshToken getTokenAsync:callback]; + } + else + { + callback(nil); + } + } +} + +- (void)getAccessTokenForScopeAsync:(NSString*)scope callback:(void (^)(NSString*))callback +{ + @synchronized(self) + { + MSATokenCacheItem* item = [_cachedAccessTokens valueForKey:scope]; + if (item) + { + [item getTokenAsync:callback]; + } + else + { + callback(nil); + } + } +} + +- (NSArray*)allScopes +{ + return [_cachedAccessTokens allKeys]; +} + +- (BOOL)loadSavedRefreshToken +{ + MSARefreshTokenCacheItem* loadedRefreshToken = [MSARefreshTokenCacheItem loadSavedRefreshTokenWithParent:self]; + if (loadedRefreshToken) + { + if ([loadedRefreshToken.expirationDate compare:[NSDate date]] != NSOrderedDescending) + { + NSLog(@"Refresh token loaded from keychain was expired. Ignoring."); + return NO; + } + + @synchronized(self) + { + _cachedRefreshToken = loadedRefreshToken; + [self markAllTokensExpired]; // Force a refresh on everything on first use + } + } + + return (loadedRefreshToken != nil); +} + +- (void)clearTokens +{ + NSLog(@"Clearing token data from cache..."); + + @synchronized(self) + { + [_cachedAccessTokens removeAllObjects]; + _cachedRefreshToken = nil; + } + + // clang-format off + NSDictionary* keychainDeleteQuery = @{ + (id) kSecClass : (id) kSecClassGenericPassword, + (id) kSecAttrGeneric : _clientId + }; + // clang-format on + + OSStatus keychainStatus = SecItemDelete((CFDictionaryRef)keychainDeleteQuery); + if (keychainStatus != errSecSuccess) + { + NSLog(@"Unable to clear token data from keychain with OSStatus %d. Data might still be loaded on next run.", (int)keychainStatus); + } + + NSLog(@"Done clearing token data from cache."); +} + +- (void)markAccessTokensExpired +{ + @synchronized(self) + { + for (MSATokenCacheItem* cachedAccessToken in _cachedAccessTokens.allValues) + { + cachedAccessToken.expirationDate = [NSDate distantPast]; + } + } +} + +- (void)markAllTokensExpired +{ + @synchronized(self) + { + _cachedRefreshToken.expirationDate = [NSDate distantPast]; + [self markAccessTokensExpired]; + } +} + +@end diff --git a/iOS/samples/GraphNotificationsSample/SampleAccountProviders/MSATokenRequest.h b/iOS/samples/GraphNotificationsSample/SampleAccountProviders/MSATokenRequest.h new file mode 100644 index 0000000..594d98f --- /dev/null +++ b/iOS/samples/GraphNotificationsSample/SampleAccountProviders/MSATokenRequest.h @@ -0,0 +1,66 @@ +// +// Copyright (c) Microsoft Corporation. All rights reserved. +// + +#pragma once + +#import + +extern NSString* _Nonnull const MsaTokenRequestGrantTypeCode; +extern NSString* _Nonnull const MsaTokenRequestGrantTypeRefresh; + +typedef NS_ENUM(NSInteger, MSATokenRequestStatus) +{ + MSATokenRequestStatusSuccess, + MSATokenRequestStatusTransientFailure, + MSATokenRequestStatusPermanentFailure +}; + +@interface MSATokenRequestResult : NSObject +@property(readwrite, nonatomic) MSATokenRequestStatus status; + +@property(readwrite, nullable, nonatomic, copy) NSString* accessToken; +@property(readwrite, nullable, nonatomic, copy) NSString* refreshToken; +@property(readwrite, nonatomic) NSInteger expiresIn; + +@end + +/** + * @brief Encapsulates a noninteractive request for an MSA token. + * This request may be performed multiple times. + */ +@interface MSATokenRequest : NSObject + +/** + * Fetches Token (Access or Refresh Token). + * clientId - clientId of the app's registration in the MSA portal + * grantType - one of the MsaTokenRequestGrantType constants + * scope + * redirectUri + * token - authCode for MsaTokenRequestGrantTypeCode, or refresh token for MsaTokenRequestGrantTypeRefresh + */ ++ (void)doAsyncRequestWithClientId:(nonnull NSString*)clientId + grantType:(nonnull NSString*)grantType + scope:(nullable NSString*)scope + redirectUri:(nullable NSString*)redirectUri + token:(nonnull NSString*)token + callback:(nonnull void (^)(MSATokenRequestResult* _Nonnull result))callback; + ++ (nullable instancetype)tokenRequestWithClientId:(nonnull NSString*)clientId + grantType:(nonnull NSString*)grantType + scope:(nullable NSString*)scope + redirectUri:(nullable NSString*)redirectUri; + +- (nullable instancetype)initWithClientId:(nonnull NSString*)clientId + grantType:(nonnull NSString*)grantType + scope:(nullable NSString*)scope + redirectUri:(nullable NSString*)redirectUri; + +@property(readonly, nonnull, nonatomic, copy) NSString* clientId; +@property(readonly, nonnull, nonatomic, copy) NSString* grantType; +@property(readonly, nullable, nonatomic, copy) NSString* scope; +@property(readonly, nullable, nonatomic, copy) NSString* redirectUri; + +- (void)requestAsyncWithToken:(nonnull NSString*)token callback:(nonnull void (^)(MSATokenRequestResult* _Nonnull result))callback; + +@end diff --git a/iOS/samples/GraphNotificationsSample/SampleAccountProviders/MSATokenRequest.m b/iOS/samples/GraphNotificationsSample/SampleAccountProviders/MSATokenRequest.m new file mode 100644 index 0000000..7ed1ff2 --- /dev/null +++ b/iOS/samples/GraphNotificationsSample/SampleAccountProviders/MSATokenRequest.m @@ -0,0 +1,165 @@ +// +// Copyright (c) Microsoft Corporation. All rights reserved. +// + +#import "MSATokenRequest.h" + +NSString* const MsaTokenRequestGrantTypeCode = @"authorization_code"; +NSString* const MsaTokenRequestGrantTypeRefresh = @"refresh_token"; + +static const NSTimeInterval MsaTokenRequestTimeout = 30.0; + +// Helper function - encodes an NSDictionary to be usable as POST data in an NSURLRequest +static NSData* EncodeDictionary(NSDictionary* dictionary) +{ + NSMutableArray* parts = [NSMutableArray new]; + for (NSString* key in dictionary) + { + NSString* encodedValue = [[dictionary objectForKey:key] stringByAddingPercentEscapesUsingEncoding:NSUTF8StringEncoding]; + NSString* encodedKey = [key stringByAddingPercentEscapesUsingEncoding:NSUTF8StringEncoding]; + + [parts addObject:[NSString stringWithFormat:@"%@=%@", encodedKey, encodedValue]]; + } + + NSString* encodedDictionary = [parts componentsJoinedByString:@"&"]; + return [encodedDictionary dataUsingEncoding:NSUTF8StringEncoding]; +} + +@interface MSATokenRequestResult () ++ (instancetype)resultWithStatus:(MSATokenRequestStatus)status responseDictionary:(nullable NSDictionary*)responseDict; +@end + +@implementation MSATokenRequestResult ++ (instancetype)resultWithStatus:(MSATokenRequestStatus)status responseDictionary:(NSDictionary*)responseDict +{ + MSATokenRequestResult* ret = [self new]; + if (ret) + { + ret.status = status; + + if (responseDict) + { + ret.accessToken = [responseDict valueForKey:@"access_token"]; + ret.refreshToken = [responseDict valueForKey:@"refresh_token"]; + ret.expiresIn = [[responseDict valueForKey:@"expires_in"] integerValue]; + } + } + return ret; +} + +@end + +@implementation MSATokenRequest + ++ (void)doAsyncRequestWithClientId:(NSString*)clientId + grantType:(NSString*)grantType + scope:(NSString*)scope + redirectUri:(NSString*)redirectUri + token:(NSString*)token + callback:(void (^)(MSATokenRequestResult*))callback +{ + + NSLog(@"Requesting token for scope %@", scope); + + NSURL* url = [NSURL URLWithString:@"https://login.live.com/oauth20_token.srf"]; + NSMutableURLRequest* request = + [NSMutableURLRequest requestWithURL:url cachePolicy:NSURLRequestUseProtocolCachePolicy timeoutInterval:MsaTokenRequestTimeout]; + [request addValue:@"application/x-www-form-urlencoded" forHTTPHeaderField:@"Content-Type"]; + + NSMutableDictionary* params = [NSMutableDictionary new]; + [params setObject:clientId forKey:@"client_id"]; + [params setObject:grantType forKey:@"grant_type"]; + if ([grantType isEqualToString:MsaTokenRequestGrantTypeCode]) + { + [params setObject:redirectUri forKey:@"redirect_uri"]; + [params setObject:token forKey:@"code"]; + } + else if ([grantType isEqualToString:MsaTokenRequestGrantTypeRefresh]) + { + if (scope) + { + [params setObject:scope forKey:@"scope"]; + } + [params setObject:token forKey:MsaTokenRequestGrantTypeRefresh]; + } + request.HTTPBody = EncodeDictionary(params); + request.HTTPMethod = @"POST"; + + static NSOperationQueue* queue = nil; + static dispatch_once_t onceToken; + dispatch_once(&onceToken, ^{ queue = [NSOperationQueue new]; }); + + NSLog(@"MSATokenRequest issuing HTTP token request."); + [NSURLConnection sendAsynchronousRequest:request + queue:queue + completionHandler:^void(NSURLResponse* response, NSData* data, NSError* error) { + + NSHTTPURLResponse* httpResponse = (NSHTTPURLResponse*)response; // This cast should always work + NSLog(@"MSATokenRequest response code %ld.", (long)httpResponse.statusCode); + + MSATokenRequestStatus status = MSATokenRequestStatusTransientFailure; + if (httpResponse.statusCode >= 500) + { + status = MSATokenRequestStatusTransientFailure; + } + else if (httpResponse.statusCode >= 400) + { + status = MSATokenRequestStatusPermanentFailure; + } + else if ((httpResponse.statusCode >= 200 && httpResponse.statusCode < 300) || httpResponse.statusCode == 304) + { + status = MSATokenRequestStatusSuccess; + } + else + { + status = MSATokenRequestStatusTransientFailure; + } + + if (data) + { + NSDictionary* responseDict = [NSJSONSerialization JSONObjectWithData:data options:0 error:nil]; + NSLog(@"MSATokenRequest data:%@", responseDict); + callback([MSATokenRequestResult resultWithStatus:status responseDictionary:responseDict]); + } + else + { + NSLog(@"MSATokenRequest error:%@", error); + callback([MSATokenRequestResult resultWithStatus:status responseDictionary:nil]); + } + }]; +} + ++ (instancetype)tokenRequestWithClientId:(NSString*)clientId + grantType:(NSString*)grantType + scope:(NSString*)scope + redirectUri:(NSString*)redirectUri +{ + return [[self alloc] initWithClientId:clientId grantType:grantType scope:scope redirectUri:redirectUri]; +} + +- (instancetype)initWithClientId:(NSString*)clientId + grantType:(NSString*)grantType + scope:(NSString*)scope + redirectUri:(NSString*)redirectUri +{ + if (self = [super init]) + { + _clientId = [clientId copy]; + _grantType = [grantType copy]; + _scope = [scope copy]; + _redirectUri = [redirectUri copy]; + } + return self; +} + +- (void)requestAsyncWithToken:(NSString*)token callback:(void (^)(MSATokenRequestResult*))callback +{ + [[self class] doAsyncRequestWithClientId:_clientId + grantType:_grantType + scope:_scope + redirectUri:_redirectUri + token:token + callback:callback]; +} + +@end diff --git a/iOS/samples/GraphNotificationsSample/SampleAccountProviders/SampleAccountActionFailureReason.h b/iOS/samples/GraphNotificationsSample/SampleAccountProviders/SampleAccountActionFailureReason.h new file mode 100644 index 0000000..bc8b2dc --- /dev/null +++ b/iOS/samples/GraphNotificationsSample/SampleAccountProviders/SampleAccountActionFailureReason.h @@ -0,0 +1,23 @@ +// +// Copyright (c) Microsoft Corporation. All rights reserved. +// + +#pragma once + +#import + +// @brief MSA failure reason for sign in or sign out action +typedef NS_ENUM(NSInteger, SampleAccountActionFailureReason) +{ + SampleAccountActionNoFailure, + SampleAccountActionFailureReasonAlreadySignedIn, + SampleAccountActionFailureReasonAlreadySignedOut, + SampleAccountActionFailureReasonUserCancelled, + SampleAccountActionFailureReasonFailToRetrieveAuthCode, + SampleAccountActionFailureReasonFailToRetrieveRefreshToken, + SampleAccountActionFailureReasonSigninSignOutInProgress, + SampleAccountActionFailureReasonUnknown, + SampleAccountActionFailureReasonADAL, +}; + +typedef void (^SampleAccountProviderCompletionBlock)(BOOL successful, SampleAccountActionFailureReason reason); \ No newline at end of file diff --git a/iOS/samples/GraphNotificationsSample/SampleAccountProviders/SingleUserAccountProvider.h b/iOS/samples/GraphNotificationsSample/SampleAccountProviders/SingleUserAccountProvider.h new file mode 100644 index 0000000..6f42677 --- /dev/null +++ b/iOS/samples/GraphNotificationsSample/SampleAccountProviders/SingleUserAccountProvider.h @@ -0,0 +1,17 @@ +// +// Copyright (c) Microsoft Corporation. All rights reserved. +// + +#pragma once + +#import + +#import "SampleAccountActionFailureReason.h" + +// @brief Protocol for a MCDUserAccountProvider that supports logging into/out of a single user account. +@protocol SingleUserAccountProvider +- (void)signInWithCompletionCallback:(nonnull SampleAccountProviderCompletionBlock)callback; +- (void)signOutWithCompletionCallback:(nonnull SampleAccountProviderCompletionBlock)callback; +@property(readonly, atomic) BOOL signedIn; + +@end diff --git a/iOS/samples/GraphNotificationsSample/Secrets.h.example b/iOS/samples/GraphNotificationsSample/Secrets.h.example new file mode 100644 index 0000000..8e6ceae --- /dev/null +++ b/iOS/samples/GraphNotificationsSample/Secrets.h.example @@ -0,0 +1,17 @@ +// +// Copyright (c) Microsoft Corporation. All rights reserved. +// + +#pragma once + +// These come from the converged app registration portal at apps.dev.microsoft.com +// MSA_CLIENT_ID: Id of this app's registration in the MSA portal +// AAD_CLIENT_ID: Id of this app's registration in the Azure portal +// AAD_REDIRECT_URI: A Uri that this app is registered with in the Azure portal. +// AAD is supposed to use this Uri to call the app back after login (currently not true, external requirement) +// And this app is supposed to be able to handle this Uri (currently not true) +// APP_HOST_NAME Cross-device domain of this app's registration +static NSString* const MSA_CLIENT_ID = "<>"; +static NSString* const AAD_CLIENT_ID = "<>"; +static NSString* const AAD_REDIRECT_URI = "<>"; +static NSString* const APP_HOST_NAME = "<>"; diff --git a/iOS/samples/GraphNotificationsSample/main.m b/iOS/samples/GraphNotificationsSample/main.m new file mode 100644 index 0000000..62064af --- /dev/null +++ b/iOS/samples/GraphNotificationsSample/main.m @@ -0,0 +1,16 @@ +// +// main.m +// GraphNotifications +// +// Created by Allen Ballway on 8/23/18. +// Copyright © 2018 Microsoft. All rights reserved. +// + +#import +#import "AppDelegate.h" + +int main(int argc, char * argv[]) { + @autoreleasepool { + return UIApplicationMain(argc, argv, nil, NSStringFromClass([AppDelegate class])); + } +}