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 @@
+
+
+
+
+
+
\ No newline at end of file
diff --git a/Android/samples/graphnotificationssample/app/src/main/res/layout/fragment_notifications.xml b/Android/samples/graphnotificationssample/app/src/main/res/layout/fragment_notifications.xml
new file mode 100644
index 0000000..fe3664b
--- /dev/null
+++ b/Android/samples/graphnotificationssample/app/src/main/res/layout/fragment_notifications.xml
@@ -0,0 +1,11 @@
+
+
+
+
diff --git a/Android/samples/graphnotificationssample/app/src/main/res/layout/notifications_list_item.xml b/Android/samples/graphnotificationssample/app/src/main/res/layout/notifications_list_item.xml
new file mode 100644
index 0000000..0986b46
--- /dev/null
+++ b/Android/samples/graphnotificationssample/app/src/main/res/layout/notifications_list_item.xml
@@ -0,0 +1,23 @@
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/Android/samples/graphnotificationssample/app/src/main/res/menu/menu_main.xml b/Android/samples/graphnotificationssample/app/src/main/res/menu/menu_main.xml
new file mode 100644
index 0000000..a35c8e8
--- /dev/null
+++ b/Android/samples/graphnotificationssample/app/src/main/res/menu/menu_main.xml
@@ -0,0 +1,10 @@
+
diff --git a/Android/samples/graphnotificationssample/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml b/Android/samples/graphnotificationssample/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml
new file mode 100644
index 0000000..eca70cf
--- /dev/null
+++ b/Android/samples/graphnotificationssample/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml
@@ -0,0 +1,5 @@
+
+
+
+
+
\ No newline at end of file
diff --git a/Android/samples/graphnotificationssample/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml b/Android/samples/graphnotificationssample/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml
new file mode 100644
index 0000000..eca70cf
--- /dev/null
+++ b/Android/samples/graphnotificationssample/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml
@@ -0,0 +1,5 @@
+
+
+
+
+
\ No newline at end of file
diff --git a/Android/samples/graphnotificationssample/app/src/main/res/mipmap-hdpi/ic_launcher.png b/Android/samples/graphnotificationssample/app/src/main/res/mipmap-hdpi/ic_launcher.png
new file mode 100644
index 0000000..a2f5908
Binary files /dev/null and b/Android/samples/graphnotificationssample/app/src/main/res/mipmap-hdpi/ic_launcher.png differ
diff --git a/Android/samples/graphnotificationssample/app/src/main/res/mipmap-hdpi/ic_launcher_round.png b/Android/samples/graphnotificationssample/app/src/main/res/mipmap-hdpi/ic_launcher_round.png
new file mode 100644
index 0000000..1b52399
Binary files /dev/null and b/Android/samples/graphnotificationssample/app/src/main/res/mipmap-hdpi/ic_launcher_round.png differ
diff --git a/Android/samples/graphnotificationssample/app/src/main/res/mipmap-mdpi/ic_launcher.png b/Android/samples/graphnotificationssample/app/src/main/res/mipmap-mdpi/ic_launcher.png
new file mode 100644
index 0000000..ff10afd
Binary files /dev/null and b/Android/samples/graphnotificationssample/app/src/main/res/mipmap-mdpi/ic_launcher.png differ
diff --git a/Android/samples/graphnotificationssample/app/src/main/res/mipmap-mdpi/ic_launcher_round.png b/Android/samples/graphnotificationssample/app/src/main/res/mipmap-mdpi/ic_launcher_round.png
new file mode 100644
index 0000000..115a4c7
Binary files /dev/null and b/Android/samples/graphnotificationssample/app/src/main/res/mipmap-mdpi/ic_launcher_round.png differ
diff --git a/Android/samples/graphnotificationssample/app/src/main/res/mipmap-xhdpi/ic_launcher.png b/Android/samples/graphnotificationssample/app/src/main/res/mipmap-xhdpi/ic_launcher.png
new file mode 100644
index 0000000..dcd3cd8
Binary files /dev/null and b/Android/samples/graphnotificationssample/app/src/main/res/mipmap-xhdpi/ic_launcher.png differ
diff --git a/Android/samples/graphnotificationssample/app/src/main/res/mipmap-xhdpi/ic_launcher_round.png b/Android/samples/graphnotificationssample/app/src/main/res/mipmap-xhdpi/ic_launcher_round.png
new file mode 100644
index 0000000..459ca60
Binary files /dev/null and b/Android/samples/graphnotificationssample/app/src/main/res/mipmap-xhdpi/ic_launcher_round.png differ
diff --git a/Android/samples/graphnotificationssample/app/src/main/res/mipmap-xxhdpi/ic_launcher.png b/Android/samples/graphnotificationssample/app/src/main/res/mipmap-xxhdpi/ic_launcher.png
new file mode 100644
index 0000000..8ca12fe
Binary files /dev/null and b/Android/samples/graphnotificationssample/app/src/main/res/mipmap-xxhdpi/ic_launcher.png differ
diff --git a/Android/samples/graphnotificationssample/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png b/Android/samples/graphnotificationssample/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png
new file mode 100644
index 0000000..8e19b41
Binary files /dev/null and b/Android/samples/graphnotificationssample/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png differ
diff --git a/Android/samples/graphnotificationssample/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png b/Android/samples/graphnotificationssample/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png
new file mode 100644
index 0000000..b824ebd
Binary files /dev/null and b/Android/samples/graphnotificationssample/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png differ
diff --git a/Android/samples/graphnotificationssample/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png b/Android/samples/graphnotificationssample/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png
new file mode 100644
index 0000000..4c19a13
Binary files /dev/null and b/Android/samples/graphnotificationssample/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png differ
diff --git a/Android/samples/graphnotificationssample/app/src/main/res/values/colors.xml b/Android/samples/graphnotificationssample/app/src/main/res/values/colors.xml
new file mode 100644
index 0000000..3ab3e9c
--- /dev/null
+++ b/Android/samples/graphnotificationssample/app/src/main/res/values/colors.xml
@@ -0,0 +1,6 @@
+
+
+ #3F51B5
+ #303F9F
+ #FF4081
+
diff --git a/Android/samples/graphnotificationssample/app/src/main/res/values/dimens.xml b/Android/samples/graphnotificationssample/app/src/main/res/values/dimens.xml
new file mode 100644
index 0000000..cef3abc
--- /dev/null
+++ b/Android/samples/graphnotificationssample/app/src/main/res/values/dimens.xml
@@ -0,0 +1,7 @@
+
+
+ 16dp
+ 16dp
+ 16dp
+ 8dp
+
diff --git a/Android/samples/graphnotificationssample/app/src/main/res/values/strings.xml b/Android/samples/graphnotificationssample/app/src/main/res/values/strings.xml
new file mode 100644
index 0000000..5aa00e1
--- /dev/null
+++ b/Android/samples/graphnotificationssample/app/src/main/res/values/strings.xml
@@ -0,0 +1,11 @@
+
+ Graph Notifications
+ Tab 1
+ Tab 2
+ Tab 3
+ Settings
+ Hello World from section: %1$d
+ Login with Work/School Account
+ Login with Personal Account
+ Log Out
+
diff --git a/Android/samples/graphnotificationssample/app/src/main/res/values/styles.xml b/Android/samples/graphnotificationssample/app/src/main/res/values/styles.xml
new file mode 100644
index 0000000..545b9c6
--- /dev/null
+++ b/Android/samples/graphnotificationssample/app/src/main/res/values/styles.xml
@@ -0,0 +1,20 @@
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/Android/samples/graphnotificationssample/app/src/test/java/com/microsoft/connecteddevices/graphnotifications/ExampleUnitTest.java b/Android/samples/graphnotificationssample/app/src/test/java/com/microsoft/connecteddevices/graphnotifications/ExampleUnitTest.java
new file mode 100644
index 0000000..75a78d8
--- /dev/null
+++ b/Android/samples/graphnotificationssample/app/src/test/java/com/microsoft/connecteddevices/graphnotifications/ExampleUnitTest.java
@@ -0,0 +1,17 @@
+package com.microsoft.connecteddevices.graphnotifications;
+
+import org.junit.Test;
+
+import static org.junit.Assert.*;
+
+/**
+ * Example local unit test, which will execute on the development machine (host).
+ *
+ * @see Testing documentation
+ */
+public class ExampleUnitTest {
+ @Test
+ public void addition_isCorrect() throws Exception {
+ assertEquals(4, 2 + 2);
+ }
+}
\ No newline at end of file
diff --git a/Android/samples/graphnotificationssample/build.gradle b/Android/samples/graphnotificationssample/build.gradle
new file mode 100644
index 0000000..d8a9941
--- /dev/null
+++ b/Android/samples/graphnotificationssample/build.gradle
@@ -0,0 +1,32 @@
+// Top-level build file where you can add configuration options common to all sub-projects/modules.
+
+buildscript {
+
+ repositories {
+ google()
+ jcenter()
+ }
+ dependencies {
+ classpath 'com.android.tools.build:gradle:3.1.4'
+ classpath 'com.google.gms:google-services:4.0.2'
+
+
+ // NOTE: Do not place your application dependencies here; they belong
+ // in the individual module build.gradle files
+ }
+}
+
+allprojects {
+ repositories {
+ google()
+ jcenter()
+ mavenCentral()
+ flatDir {
+ dirs 'libs'
+ }
+ }
+}
+
+task clean(type: Delete) {
+ delete rootProject.buildDir
+}
diff --git a/Android/samples/graphnotificationssample/gradle.properties b/Android/samples/graphnotificationssample/gradle.properties
new file mode 100644
index 0000000..aac7c9b
--- /dev/null
+++ b/Android/samples/graphnotificationssample/gradle.properties
@@ -0,0 +1,17 @@
+# Project-wide Gradle settings.
+
+# IDE (e.g. Android Studio) users:
+# Gradle settings configured through the IDE *will override*
+# any settings specified in this file.
+
+# For more details on how to configure your build environment visit
+# http://www.gradle.org/docs/current/userguide/build_environment.html
+
+# Specifies the JVM arguments used for the daemon process.
+# The setting is particularly useful for tweaking memory settings.
+org.gradle.jvmargs=-Xmx1536m
+
+# When configured, Gradle will run in incubating parallel mode.
+# This option should only be used with decoupled projects. More details, visit
+# http://www.gradle.org/docs/current/userguide/multi_project_builds.html#sec:decoupled_projects
+# org.gradle.parallel=true
diff --git a/Android/samples/graphnotificationssample/gradle/wrapper/gradle-wrapper.jar b/Android/samples/graphnotificationssample/gradle/wrapper/gradle-wrapper.jar
new file mode 100644
index 0000000..13372ae
Binary files /dev/null and b/Android/samples/graphnotificationssample/gradle/wrapper/gradle-wrapper.jar differ
diff --git a/Android/samples/graphnotificationssample/gradle/wrapper/gradle-wrapper.properties b/Android/samples/graphnotificationssample/gradle/wrapper/gradle-wrapper.properties
new file mode 100644
index 0000000..e3c1faf
--- /dev/null
+++ b/Android/samples/graphnotificationssample/gradle/wrapper/gradle-wrapper.properties
@@ -0,0 +1,6 @@
+#Mon Aug 13 09:22:18 PDT 2018
+distributionBase=GRADLE_USER_HOME
+distributionPath=wrapper/dists
+zipStoreBase=GRADLE_USER_HOME
+zipStorePath=wrapper/dists
+distributionUrl=https\://services.gradle.org/distributions/gradle-4.4-all.zip
diff --git a/Android/samples/graphnotificationssample/gradlew b/Android/samples/graphnotificationssample/gradlew
new file mode 100644
index 0000000..9d82f78
--- /dev/null
+++ b/Android/samples/graphnotificationssample/gradlew
@@ -0,0 +1,160 @@
+#!/usr/bin/env bash
+
+##############################################################################
+##
+## Gradle start up script for UN*X
+##
+##############################################################################
+
+# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
+DEFAULT_JVM_OPTS=""
+
+APP_NAME="Gradle"
+APP_BASE_NAME=`basename "$0"`
+
+# Use the maximum available, or set MAX_FD != -1 to use that value.
+MAX_FD="maximum"
+
+warn ( ) {
+ echo "$*"
+}
+
+die ( ) {
+ echo
+ echo "$*"
+ echo
+ exit 1
+}
+
+# OS specific support (must be 'true' or 'false').
+cygwin=false
+msys=false
+darwin=false
+case "`uname`" in
+ CYGWIN* )
+ cygwin=true
+ ;;
+ Darwin* )
+ darwin=true
+ ;;
+ MINGW* )
+ msys=true
+ ;;
+esac
+
+# Attempt to set APP_HOME
+# Resolve links: $0 may be a link
+PRG="$0"
+# Need this for relative symlinks.
+while [ -h "$PRG" ] ; do
+ ls=`ls -ld "$PRG"`
+ link=`expr "$ls" : '.*-> \(.*\)$'`
+ if expr "$link" : '/.*' > /dev/null; then
+ PRG="$link"
+ else
+ PRG=`dirname "$PRG"`"/$link"
+ fi
+done
+SAVED="`pwd`"
+cd "`dirname \"$PRG\"`/" >/dev/null
+APP_HOME="`pwd -P`"
+cd "$SAVED" >/dev/null
+
+CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar
+
+# Determine the Java command to use to start the JVM.
+if [ -n "$JAVA_HOME" ] ; then
+ if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
+ # IBM's JDK on AIX uses strange locations for the executables
+ JAVACMD="$JAVA_HOME/jre/sh/java"
+ else
+ JAVACMD="$JAVA_HOME/bin/java"
+ fi
+ if [ ! -x "$JAVACMD" ] ; then
+ die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
+
+Please set the JAVA_HOME variable in your environment to match the
+location of your Java installation."
+ fi
+else
+ JAVACMD="java"
+ which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
+
+Please set the JAVA_HOME variable in your environment to match the
+location of your Java installation."
+fi
+
+# Increase the maximum file descriptors if we can.
+if [ "$cygwin" = "false" -a "$darwin" = "false" ] ; then
+ MAX_FD_LIMIT=`ulimit -H -n`
+ if [ $? -eq 0 ] ; then
+ if [ "$MAX_FD" = "maximum" -o "$MAX_FD" = "max" ] ; then
+ MAX_FD="$MAX_FD_LIMIT"
+ fi
+ ulimit -n $MAX_FD
+ if [ $? -ne 0 ] ; then
+ warn "Could not set maximum file descriptor limit: $MAX_FD"
+ fi
+ else
+ warn "Could not query maximum file descriptor limit: $MAX_FD_LIMIT"
+ fi
+fi
+
+# For Darwin, add options to specify how the application appears in the dock
+if $darwin; then
+ GRADLE_OPTS="$GRADLE_OPTS \"-Xdock:name=$APP_NAME\" \"-Xdock:icon=$APP_HOME/media/gradle.icns\""
+fi
+
+# For Cygwin, switch paths to Windows format before running java
+if $cygwin ; then
+ APP_HOME=`cygpath --path --mixed "$APP_HOME"`
+ CLASSPATH=`cygpath --path --mixed "$CLASSPATH"`
+ JAVACMD=`cygpath --unix "$JAVACMD"`
+
+ # We build the pattern for arguments to be converted via cygpath
+ ROOTDIRSRAW=`find -L / -maxdepth 1 -mindepth 1 -type d 2>/dev/null`
+ SEP=""
+ for dir in $ROOTDIRSRAW ; do
+ ROOTDIRS="$ROOTDIRS$SEP$dir"
+ SEP="|"
+ done
+ OURCYGPATTERN="(^($ROOTDIRS))"
+ # Add a user-defined pattern to the cygpath arguments
+ if [ "$GRADLE_CYGPATTERN" != "" ] ; then
+ OURCYGPATTERN="$OURCYGPATTERN|($GRADLE_CYGPATTERN)"
+ fi
+ # Now convert the arguments - kludge to limit ourselves to /bin/sh
+ i=0
+ for arg in "$@" ; do
+ CHECK=`echo "$arg"|egrep -c "$OURCYGPATTERN" -`
+ CHECK2=`echo "$arg"|egrep -c "^-"` ### Determine if an option
+
+ if [ $CHECK -ne 0 ] && [ $CHECK2 -eq 0 ] ; then ### Added a condition
+ eval `echo args$i`=`cygpath --path --ignore --mixed "$arg"`
+ else
+ eval `echo args$i`="\"$arg\""
+ fi
+ i=$((i+1))
+ done
+ case $i in
+ (0) set -- ;;
+ (1) set -- "$args0" ;;
+ (2) set -- "$args0" "$args1" ;;
+ (3) set -- "$args0" "$args1" "$args2" ;;
+ (4) set -- "$args0" "$args1" "$args2" "$args3" ;;
+ (5) set -- "$args0" "$args1" "$args2" "$args3" "$args4" ;;
+ (6) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" ;;
+ (7) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" ;;
+ (8) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" ;;
+ (9) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" "$args8" ;;
+ esac
+fi
+
+# Split up the JVM_OPTS And GRADLE_OPTS values into an array, following the shell quoting and substitution rules
+function splitJvmOpts() {
+ JVM_OPTS=("$@")
+}
+eval splitJvmOpts $DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS
+JVM_OPTS[${#JVM_OPTS[*]}]="-Dorg.gradle.appname=$APP_BASE_NAME"
+
+exec "$JAVACMD" "${JVM_OPTS[@]}" -classpath "$CLASSPATH" org.gradle.wrapper.GradleWrapperMain "$@"
diff --git a/Android/samples/graphnotificationssample/gradlew.bat b/Android/samples/graphnotificationssample/gradlew.bat
new file mode 100644
index 0000000..8a0b282
--- /dev/null
+++ b/Android/samples/graphnotificationssample/gradlew.bat
@@ -0,0 +1,90 @@
+@if "%DEBUG%" == "" @echo off
+@rem ##########################################################################
+@rem
+@rem Gradle startup script for Windows
+@rem
+@rem ##########################################################################
+
+@rem Set local scope for the variables with windows NT shell
+if "%OS%"=="Windows_NT" setlocal
+
+@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
+set DEFAULT_JVM_OPTS=
+
+set DIRNAME=%~dp0
+if "%DIRNAME%" == "" set DIRNAME=.
+set APP_BASE_NAME=%~n0
+set APP_HOME=%DIRNAME%
+
+@rem Find java.exe
+if defined JAVA_HOME goto findJavaFromJavaHome
+
+set JAVA_EXE=java.exe
+%JAVA_EXE% -version >NUL 2>&1
+if "%ERRORLEVEL%" == "0" goto init
+
+echo.
+echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
+echo.
+echo Please set the JAVA_HOME variable in your environment to match the
+echo location of your Java installation.
+
+goto fail
+
+:findJavaFromJavaHome
+set JAVA_HOME=%JAVA_HOME:"=%
+set JAVA_EXE=%JAVA_HOME%/bin/java.exe
+
+if exist "%JAVA_EXE%" goto init
+
+echo.
+echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME%
+echo.
+echo Please set the JAVA_HOME variable in your environment to match the
+echo location of your Java installation.
+
+goto fail
+
+:init
+@rem Get command-line arguments, handling Windowz variants
+
+if not "%OS%" == "Windows_NT" goto win9xME_args
+if "%@eval[2+2]" == "4" goto 4NT_args
+
+:win9xME_args
+@rem Slurp the command line arguments.
+set CMD_LINE_ARGS=
+set _SKIP=2
+
+:win9xME_args_slurp
+if "x%~1" == "x" goto execute
+
+set CMD_LINE_ARGS=%*
+goto execute
+
+:4NT_args
+@rem Get arguments from the 4NT Shell from JP Software
+set CMD_LINE_ARGS=%$
+
+:execute
+@rem Setup the command line
+
+set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar
+
+@rem Execute Gradle
+"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %CMD_LINE_ARGS%
+
+:end
+@rem End local scope for the variables with windows NT shell
+if "%ERRORLEVEL%"=="0" goto mainEnd
+
+:fail
+rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
+rem the _cmd.exe /c_ return code!
+if not "" == "%GRADLE_EXIT_CONSOLE%" exit 1
+exit /b 1
+
+:mainEnd
+if "%OS%"=="Windows_NT" endlocal
+
+:omega
diff --git a/Android/samples/graphnotificationssample/sampleaccountproviders/android/build.gradle b/Android/samples/graphnotificationssample/sampleaccountproviders/android/build.gradle
new file mode 100644
index 0000000..6f75a07
--- /dev/null
+++ b/Android/samples/graphnotificationssample/sampleaccountproviders/android/build.gradle
@@ -0,0 +1,48 @@
+apply plugin: 'com.android.library'
+
+android {
+ compileSdkVersion 27
+ buildToolsVersion '27.0.3'
+
+ defaultConfig {
+ minSdkVersion 19
+ targetSdkVersion 27
+
+ buildConfigField "String", "SDK_FLAVOR", '"external"'
+ }
+
+ buildTypes {
+ release {
+ minifyEnabled false
+ }
+ debug {
+ jniDebuggable true
+ debuggable true
+ }
+ }
+
+ sourceSets {
+ main {
+ java.srcDirs = ['./src']
+ manifest.srcFile 'src/AndroidManifest.xml'
+ }
+ }
+}
+
+dependencies {
+ implementation 'com.android.support:support-annotations:27.1.1'
+
+ // TODO 17974277: adal 1.13.0 and later calls its webview methods on a private thread
+ // msa calls its webview methods on the ui thread
+ // chromium expects the same thread to be used to call webview methods, will throw runtime exception otherwise
+ // work around by staying at adal 1.12.0 for now
+
+ // override adal 1.12.0's use of 2.2.4 to resolve dependency conflict
+ api 'com.google.code.gson:gson:2.7'
+ implementation('com.microsoft.aad:adal:1.12.0') {
+ exclude group: 'com.android.support'
+ }
+
+ // Local version of SDK, since we don't have public maven (yet)
+ implementation(name:'connecteddevices-sdk', ext:'aar')
+}
diff --git a/Android/samples/graphnotificationssample/sampleaccountproviders/android/proguard-rules.txt b/Android/samples/graphnotificationssample/sampleaccountproviders/android/proguard-rules.txt
new file mode 100644
index 0000000..3b4317f
--- /dev/null
+++ b/Android/samples/graphnotificationssample/sampleaccountproviders/android/proguard-rules.txt
@@ -0,0 +1,44 @@
+# OneSDK Consumer Proguard Rules
+# This file defines Proguard rules required by the OneSDK, and is intended to be distributed
+# as part of the Android library for consuming apps.
+
+# Set attributes to modify default Proguard behavior:
+# *Annotation* - Prevents annotation removal
+# Signature - Preserves generic method signature info
+# SourceFile - Preservces source file info for stack traces
+# LineNumberTable - Preserves original line number info for stack traces
+# EnclosingMethod - Preserves info for methods in which classes are defined
+# InnerClasses - Prevents removal of inner classes
+-keepattributes *Annotation*, Signature, SourceFile, LineNumberTable, EnclosingMethod, InnerClasses
+
+# Prevent removal of the Keep annotation
+-keep @interface android.support.annotation.Keep
+
+# Ensure Keep annotated classes and interfaces are actually kept
+-keep @android.support.annotation.Keep class * {*;}
+-keep @android.support.annotation.Keep interface * {*;}
+
+# Preserve public inner interfaces (like the InnerClasses attribute, but no corresponding InnerInterfaces attribute exists)
+-keep public interface **$* {*;}
+
+# Preserve native method signatures
+-keepclasseswithmembernames,includedescriptorclasses class * {
+ native ;
+}
+
+# Preserve enum methods
+# Don't use 'allowoptimization' option as it prevents lookup via reflection
+-keepclassmembers enum * {
+ public static **[] values();
+ public static ** valueOf(java.lang.String);
+}
+
+# Preserve inner enum class methods
+# Don't use 'allowoptimization' option as it prevents lookup via reflection
+-keepclassmembers enum **$* {
+ public static **[] values();
+ public static ** valueOf(java.lang.String);
+}
+
+# Ignore warnings for the Bond libraries from CLL since they are not used
+-dontwarn com.microsoft.bond.**
\ No newline at end of file
diff --git a/Android/samples/graphnotificationssample/sampleaccountproviders/android/src/AndroidManifest.xml b/Android/samples/graphnotificationssample/sampleaccountproviders/android/src/AndroidManifest.xml
new file mode 100644
index 0000000..ce5728f
--- /dev/null
+++ b/Android/samples/graphnotificationssample/sampleaccountproviders/android/src/AndroidManifest.xml
@@ -0,0 +1,2 @@
+
+
diff --git a/Android/samples/graphnotificationssample/sampleaccountproviders/android/src/com/microsoft/connecteddevices/sampleaccountproviders/AADAccountProvider.java b/Android/samples/graphnotificationssample/sampleaccountproviders/android/src/com/microsoft/connecteddevices/sampleaccountproviders/AADAccountProvider.java
new file mode 100644
index 0000000..7ce5cae
--- /dev/null
+++ b/Android/samples/graphnotificationssample/sampleaccountproviders/android/src/com/microsoft/connecteddevices/sampleaccountproviders/AADAccountProvider.java
@@ -0,0 +1,290 @@
+//
+// Copyright (c) Microsoft Corporation. All rights reserved.
+//
+
+package com.microsoft.connecteddevices.sampleaccountproviders;
+
+import android.annotation.TargetApi;
+import android.app.Activity;
+import android.content.Context;
+import android.content.Intent;
+import android.os.Handler;
+import android.os.Looper;
+import android.support.annotation.Keep;
+import android.support.annotation.Nullable;
+import android.text.TextUtils;
+import android.util.ArrayMap;
+import android.util.Log;
+import android.webkit.CookieManager;
+import android.webkit.CookieSyncManager;
+import android.webkit.ValueCallback;
+
+import com.microsoft.aad.adal.ADALError;
+import com.microsoft.aad.adal.AuthenticationCallback;
+import com.microsoft.aad.adal.AuthenticationContext;
+import com.microsoft.aad.adal.AuthenticationException;
+import com.microsoft.aad.adal.AuthenticationResult;
+import com.microsoft.aad.adal.AuthenticationResult.AuthenticationStatus;
+import com.microsoft.aad.adal.PromptBehavior;
+import com.microsoft.aad.adal.TokenCacheItem;
+
+import com.microsoft.connecteddevices.core.AccessTokenRequestStatus;
+import com.microsoft.connecteddevices.core.AccessTokenResult;
+import com.microsoft.connecteddevices.base.AsyncOperation;
+import com.microsoft.connecteddevices.base.EventListener;
+import com.microsoft.connecteddevices.core.UserAccountProvider;
+import com.microsoft.connecteddevices.core.UserAccount;
+import com.microsoft.connecteddevices.core.UserAccountType;
+
+import java.lang.InterruptedException;
+import java.util.Iterator;
+import java.util.Map;
+
+/**
+ * Sign in helper that provides an UserAccountProvider implementation for AAD using the ADAL library.
+ * To use this class, call signIn()/signOut(), then use the standard UserAccountProvider functions.
+ *
+ * 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.
+ */
+@Keep
+public final class AADAccountProvider implements UserAccountProvider {
+ private static final String TAG = AADAccountProvider.class.getName();
+
+ private final String mClientId;
+ private final String mRedirectUri;
+ private final AuthenticationContext mAuthContext;
+
+ private UserAccount mAccount; // Initialized when signed in
+
+ private final Map> mListenerMap = new ArrayMap<>();
+ private long mNextListenerId = 1L;
+
+ /**
+ * @param clientId id of the app's registration in the Azure portal
+ * @param redirectUri redirect uri the app is registered with in the Azure portal
+ * @param context
+ */
+ public AADAccountProvider(String clientId, String redirectUri, Context context) {
+ mClientId = clientId;
+ mRedirectUri = redirectUri;
+
+ if (android.os.Build.VERSION.SDK_INT < android.os.Build.VERSION_CODES.LOLLIPOP) {
+ CookieSyncManager.createInstance(context);
+ }
+
+ mAuthContext = new AuthenticationContext(context, "https://login.microsoftonline.com/common", false);
+
+ Log.i(TAG, "Checking if previous AADAccountProvider session can be loaded...");
+ Iterator tokenCacheItems = mAuthContext.getCache().getAll();
+ while (tokenCacheItems.hasNext()) {
+ TokenCacheItem item = tokenCacheItems.next();
+ if (item.getIsMultiResourceRefreshToken() && item.getClientId().equals(mClientId)) {
+ mAccount = new UserAccount(item.getUserInfo().getUserId(), UserAccountType.AAD);
+ break;
+ }
+ }
+
+ if (mAccount != null) {
+ Log.i(TAG, "Loaded previous AADAccountProvider session, starting as signed in.");
+ } else {
+ Log.i(TAG, "No previous AADAccountProvider session could be loaded, starting as signed out.");
+ }
+ }
+
+ private AsyncOperation notifyListenersAsync() {
+ return AsyncOperation.supplyAsync(new AsyncOperation.Supplier() {
+ @Override
+ public Void get() {
+ for (EventListener listener : mListenerMap.values()) {
+ listener.onEvent(AADAccountProvider.this, null);
+ }
+ return null;
+ }
+ });
+ }
+
+ public String getClientId() {
+ return mClientId;
+ }
+
+ public synchronized boolean isSignedIn() {
+ return mAccount != null;
+ }
+
+ public synchronized AsyncOperation signIn() throws IllegalStateException {
+ if (isSignedIn()) {
+ throw new IllegalStateException("AADAccountProvider: Already signed in!");
+ }
+
+ final AsyncOperation ret = new AsyncOperation<>();
+
+ // 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 getAccessTokenForUserAccountAsync()
+ final String defaultResource = "https://graph.windows.net";
+
+ mAuthContext.acquireToken( //
+ defaultResource, // resource
+ mClientId, // clientId
+ mRedirectUri, // redirectUri
+ null, // loginHint
+ PromptBehavior.Auto, // promptBehavior
+ null, // extraQueryParameters
+ new AuthenticationCallback() {
+ @Override
+ public void onError(Exception e) {
+ Log.e(TAG, "acquireToken encountered an exception: " + e.toString() + ". This may be transient.");
+ ret.complete(false);
+ }
+
+ @Override
+ public void onSuccess(AuthenticationResult result) {
+ if (result == null || result.getStatus() != AuthenticationStatus.Succeeded || result.getUserInfo() == null) {
+ ret.complete(false);
+ } else {
+ mAccount = new UserAccount(result.getUserInfo().getUserId(), UserAccountType.AAD);
+ ret.complete(true);
+ notifyListenersAsync();
+ }
+ }
+ });
+
+ return ret;
+ }
+
+ public synchronized void signOut() throws IllegalStateException {
+ if (!isSignedIn()) {
+ throw new IllegalStateException("AADAccountProvider: Not currently signed in!");
+ }
+
+ // Delete cookies
+ final CookieManager cookieManager = CookieManager.getInstance();
+
+ if (android.os.Build.VERSION.SDK_INT < android.os.Build.VERSION_CODES.LOLLIPOP) {
+ cookieManager.removeAllCookie();
+ CookieSyncManager.getInstance().sync();
+ } else {
+ cookieManager.removeAllCookies(new ValueCallback() {
+ @Override
+ @TargetApi(21)
+ public void onReceiveValue(Boolean value) {
+ cookieManager.flush();
+ }
+ });
+ }
+
+ mAccount = null;
+ mAuthContext.getCache().removeAll();
+ notifyListenersAsync();
+ }
+
+ @Override
+ public synchronized UserAccount[] getUserAccounts() {
+ if (mAccount != null) {
+ return new UserAccount[] { mAccount };
+ }
+
+ return new UserAccount[0];
+ }
+
+ @Override
+ public synchronized AsyncOperation getAccessTokenForUserAccountAsync(
+ final String userAccountId, final String[] scopes) {
+ if (mAccount == null || !mAccount.getId().equals(userAccountId)) {
+ return AsyncOperation.completedFuture(new AccessTokenResult(AccessTokenRequestStatus.TRANSIENT_ERROR, null));
+ }
+
+ final AsyncOperation ret = new AsyncOperation<>();
+ mAuthContext.acquireTokenSilentAsync(scopes[0], mClientId, mAccount.getId(), new AuthenticationCallback() {
+ @Override
+ public void onError(Exception e) {
+ if ((e instanceof AuthenticationException) &&
+ ((AuthenticationException)e).getCode() == ADALError.AUTH_REFRESH_FAILED_PROMPT_NOT_ALLOWED) {
+ // This error only returns from acquireTokenSilentAsync 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 signIn().
+ // 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.
+ Log.i(TAG, "A resource was requested that the user did not previously consent to. "
+ + "Attempting to raise an interactive consent prompt.");
+
+ final AuthenticationCallback reusedCallback = this; // reuse this callback
+ new Handler(Looper.getMainLooper())
+ .post(new Runnable() {
+ @Override
+ public void run() {
+ mAuthContext.acquireToken(
+ scopes[0], mClientId, mRedirectUri, null, PromptBehavior.Auto, null, reusedCallback);
+ }
+ });
+ return;
+ }
+
+ Log.e(TAG, "getAccessTokenForUserAccountAsync hit an exception: " + e.toString() + ". This may be transient.");
+ ret.complete(new AccessTokenResult(AccessTokenRequestStatus.TRANSIENT_ERROR, null));
+ }
+
+ @Override
+ public void onSuccess(AuthenticationResult result) {
+ if (result == null || result.getStatus() != AuthenticationStatus.Succeeded || TextUtils.isEmpty(result.getAccessToken())) {
+
+ ret.complete(new AccessTokenResult(AccessTokenRequestStatus.TRANSIENT_ERROR, null));
+ } else {
+ ret.complete(new AccessTokenResult(AccessTokenRequestStatus.SUCCESS, result.getAccessToken()));
+ }
+ }
+ });
+
+ return ret;
+ }
+
+ @Override
+ public synchronized long addUserAccountChangedListener(EventListener listener) {
+ long id = mNextListenerId++;
+ mListenerMap.put(id, listener);
+ return id;
+ }
+
+ @Override
+ public synchronized void removeUserAccountChangedListener(long id) {
+ mListenerMap.remove(id);
+ }
+
+ @Override
+ public synchronized void onAccessTokenError(String userAccountId, String[] scopes, boolean isPermanentError) {
+ if (mAccount != null && mAccount.getId().equals(userAccountId)) {
+ if (isPermanentError) {
+ try {
+ signOut();
+ } catch (IllegalStateException e) {
+ // Already signed out in between checking if signed in and now. No need to do anything.
+ Log.e(TAG, "Already signed out in onAccessTokenError. This error is most likely benign: " + e.toString());
+ }
+ } else {
+ // If not a permanent error, try to refresh the tokens
+ try {
+ mAuthContext.acquireTokenSilentSync(scopes[0], mClientId, userAccountId);
+ } catch (AuthenticationException e) {
+ Log.e(TAG, "Exception in ADAL when trying to refresh token: \'" + e.toString() + "\'");
+ } catch (InterruptedException e) { Log.e(TAG, "Interrupted while trying to refresh token: \'" + e.toString() + "\'"); }
+ }
+ } else {
+ Log.e(TAG, "onAccessTokenError was called, but AADAccountProvider was not signed in.");
+ }
+ }
+}
\ No newline at end of file
diff --git a/Android/samples/graphnotificationssample/sampleaccountproviders/android/src/com/microsoft/connecteddevices/sampleaccountproviders/AADMSAAccountProvider.java b/Android/samples/graphnotificationssample/sampleaccountproviders/android/src/com/microsoft/connecteddevices/sampleaccountproviders/AADMSAAccountProvider.java
new file mode 100644
index 0000000..5a37487
--- /dev/null
+++ b/Android/samples/graphnotificationssample/sampleaccountproviders/android/src/com/microsoft/connecteddevices/sampleaccountproviders/AADMSAAccountProvider.java
@@ -0,0 +1,173 @@
+//
+// Copyright (c) Microsoft Corporation. All rights reserved.
+//
+
+package com.microsoft.connecteddevices.sampleaccountproviders;
+
+import android.app.Activity;
+import android.content.Context;
+import android.content.Intent;
+import android.support.annotation.Keep;
+import android.util.Log;
+
+import com.microsoft.connecteddevices.base.AsyncOperation;
+import com.microsoft.connecteddevices.base.EventListener;
+import com.microsoft.connecteddevices.core.AccessTokenResult;
+import com.microsoft.connecteddevices.core.UserAccountProvider;
+import com.microsoft.connecteddevices.core.UserAccount;
+
+import java.util.Hashtable;
+import java.util.Map;
+
+/**
+ * Sign in helper that provides an UserAccountProvider implementation that works with both AAD and MSA accounts.
+ *
+ * To use this class, call signInMSA()/signOutMSA()/signInAAD()/signOutAAD(),
+ * then access the UserAccountProvider through getUserAccountProvider().
+ */
+@Keep
+public final class AADMSAAccountProvider implements UserAccountProvider {
+ private static final String TAG = AADMSAAccountProvider.class.getName();
+
+ public enum State {
+ SignedOut,
+ SignedInMSA,
+ SignedInAAD,
+ }
+
+ private MSAAccountProvider mMSAProvider;
+ private AADAccountProvider mAADProvider;
+ private EventListener mListener;
+
+ private final Map> mListenerMap = new Hashtable<>();
+ private long mNextListenerId = 1L;
+
+ /**
+ * @param msaClientId id of the app's registration in the MSA portal
+ * @param msaScopeOverrides scope overrides for the app
+ * @param aadClientId id of the app's registration in the Azure portal
+ * @param aadRedirectUri redirect uri the app is registered with in the Azure portal
+ * @param context
+ */
+ public AADMSAAccountProvider(
+ String msaClientId, final Map msaScopeOverrides, String aadClientId, String aadRedirectUri, Context context) {
+
+ // Chain the inner events to the event provided by this helper
+ mListener = new EventListener() {
+ @Override
+ public void onEvent(UserAccountProvider provider, Void aVoid) {
+ notifyListenersAsync();
+ }
+ };
+
+ mMSAProvider = new MSAAccountProvider(msaClientId, msaScopeOverrides, context);
+ mAADProvider = new AADAccountProvider(aadClientId, aadRedirectUri, context);
+
+ if (mMSAProvider.isSignedIn() && mAADProvider.isSignedIn()) {
+ // Shouldn't ever happen, but if it does, sign AAD out
+ mAADProvider.signOut();
+ }
+
+ mMSAProvider.addUserAccountChangedListener(mListener);
+ mAADProvider.addUserAccountChangedListener(mListener);
+ }
+
+ private AsyncOperation notifyListenersAsync() {
+ return AsyncOperation.supplyAsync(new AsyncOperation.Supplier() {
+ @Override
+ public Void get() {
+ for (EventListener listener : mListenerMap.values()) {
+ listener.onEvent(AADMSAAccountProvider.this, null);
+ }
+ return null;
+ }
+ });
+ }
+
+ public synchronized State getSignInState() {
+ if (mMSAProvider != null && mMSAProvider.isSignedIn()) {
+ return State.SignedInMSA;
+ }
+ if (mAADProvider != null && mAADProvider.isSignedIn()) {
+ return State.SignedInAAD;
+ }
+ return State.SignedOut;
+ }
+
+ public AsyncOperation signInMSA(final Activity currentActivity) throws IllegalStateException {
+ if (getSignInState() != State.SignedOut) {
+ throw new IllegalStateException("Already signed into an account!");
+ }
+ return mMSAProvider.signIn(currentActivity);
+ }
+
+ public void signOutMSA(final Activity currentActivity) throws IllegalStateException {
+ if (getSignInState() != State.SignedInMSA) {
+ throw new IllegalStateException("Not currently signed into an MSA!");
+ }
+ mMSAProvider.signOut(currentActivity);
+ }
+
+ public AsyncOperation signInAAD() throws IllegalStateException {
+ if (getSignInState() != State.SignedOut) {
+ throw new IllegalStateException("Already signed into an account!");
+ }
+ return mAADProvider.signIn();
+ }
+
+ public void signOutAAD() throws IllegalStateException {
+ if (getSignInState() != State.SignedInAAD) {
+ throw new IllegalStateException("Not currently signed into an AAD account!");
+ }
+ mAADProvider.signOut();
+ }
+
+ private UserAccountProvider getSignedInProvider() {
+ switch (getSignInState()) {
+ case SignedInMSA: return mMSAProvider;
+ case SignedInAAD: return mAADProvider;
+ default: return null;
+ }
+ }
+
+ @Override
+ public synchronized UserAccount[] getUserAccounts() {
+ UserAccountProvider provider = AADMSAAccountProvider.this.getSignedInProvider();
+ return (provider != null) ? provider.getUserAccounts() : new UserAccount[0];
+ }
+
+ @Override
+ public synchronized AsyncOperation getAccessTokenForUserAccountAsync(
+ final String userAccountId, final String[] scopes) {
+ UserAccountProvider provider = AADMSAAccountProvider.this.getSignedInProvider();
+ if (provider != null) {
+ return provider.getAccessTokenForUserAccountAsync(userAccountId, scopes);
+ }
+
+ AsyncOperation ret = new AsyncOperation();
+ ret.completeExceptionally(new IllegalStateException("Not currently signed in!"));
+ return ret;
+ }
+
+ @Override
+ public synchronized long addUserAccountChangedListener(EventListener listener) {
+ long id = mNextListenerId++;
+ mListenerMap.put(id, listener);
+ return id;
+ }
+
+ @Override
+ public synchronized void removeUserAccountChangedListener(long id) {
+ mListenerMap.remove(id);
+ }
+
+ @Override
+ public synchronized void onAccessTokenError(String userAccountId, String[] scopes, boolean isPermanentError) {
+ UserAccountProvider provider = AADMSAAccountProvider.this.getSignedInProvider();
+ if (provider != null) {
+ provider.onAccessTokenError(userAccountId, scopes, isPermanentError);
+ } else {
+ Log.e(TAG, "Not currently signed in!");
+ }
+ }
+}
\ No newline at end of file
diff --git a/Android/samples/graphnotificationssample/sampleaccountproviders/android/src/com/microsoft/connecteddevices/sampleaccountproviders/IOUtil.java b/Android/samples/graphnotificationssample/sampleaccountproviders/android/src/com/microsoft/connecteddevices/sampleaccountproviders/IOUtil.java
new file mode 100644
index 0000000..466eae6
--- /dev/null
+++ b/Android/samples/graphnotificationssample/sampleaccountproviders/android/src/com/microsoft/connecteddevices/sampleaccountproviders/IOUtil.java
@@ -0,0 +1,53 @@
+//
+// Copyright (c) Microsoft Corporation. All rights reserved.
+//
+
+package com.microsoft.connecteddevices.sampleaccountproviders;
+
+import android.support.annotation.Keep;
+
+import java.io.BufferedReader;
+import java.io.BufferedWriter;
+import java.io.InputStream;
+import java.io.OutputStream;
+import java.io.IOException;
+import java.io.InputStreamReader;
+import java.io.OutputStreamWriter;
+
+@Keep
+public final class IOUtil {
+
+ /**
+ * Writes UTF-8 output data to an output stream.
+ * This method is synchronous, and should only be used on small data sizes.
+ *
+ * @param stream Stream to write data to
+ * @param data Data to write
+ * @throws IOException Thrown if the output stream is unavailable, or encoding the data fails
+ */
+ public static void writeUTF8Stream(OutputStream stream, String data) throws IOException {
+ try (BufferedWriter writer = new BufferedWriter(new OutputStreamWriter(stream, "UTF-8"))) {
+ writer.write(data);
+ }
+ }
+
+ /**
+ * Reads the contents of a UTF-8 input stream.
+ * This method is synchronous, and should only be used on small data sizes.
+ *
+ * @param stream Input stream to read from
+ * @return All data received from the stream
+ * @throws IOException Thrown if the input stream is unavailable, or decoding the data fails
+ */
+ public static String readUTF8Stream(InputStream stream) throws IOException {
+ StringBuilder stringBuilder = new StringBuilder();
+ try (BufferedReader reader = new BufferedReader(new InputStreamReader(stream, "UTF-8"))) {
+ String line;
+ while ((line = reader.readLine()) != null) {
+ stringBuilder.append(line);
+ }
+ }
+
+ return stringBuilder.toString();
+ }
+}
\ No newline at end of file
diff --git a/Android/samples/graphnotificationssample/sampleaccountproviders/android/src/com/microsoft/connecteddevices/sampleaccountproviders/MSAAccountProvider.java b/Android/samples/graphnotificationssample/sampleaccountproviders/android/src/com/microsoft/connecteddevices/sampleaccountproviders/MSAAccountProvider.java
new file mode 100644
index 0000000..d3abec9
--- /dev/null
+++ b/Android/samples/graphnotificationssample/sampleaccountproviders/android/src/com/microsoft/connecteddevices/sampleaccountproviders/MSAAccountProvider.java
@@ -0,0 +1,501 @@
+//
+// Copyright (c) Microsoft Corporation. All rights reserved.
+//
+
+package com.microsoft.connecteddevices.sampleaccountproviders;
+
+import android.annotation.TargetApi;
+import android.app.Activity;
+import android.app.Dialog;
+import android.content.Context;
+import android.net.Uri;
+import android.os.Build;
+import android.support.annotation.Keep;
+import android.text.TextUtils;
+import android.util.ArrayMap;
+import android.util.Log;
+import android.webkit.WebChromeClient;
+import android.webkit.WebResourceError;
+import android.webkit.WebResourceRequest;
+import android.webkit.WebView;
+import android.webkit.WebViewClient;
+
+import com.microsoft.connecteddevices.core.AccessTokenRequestStatus;
+import com.microsoft.connecteddevices.core.AccessTokenResult;
+import com.microsoft.connecteddevices.base.AsyncOperation;
+import com.microsoft.connecteddevices.base.EventListener;
+import com.microsoft.connecteddevices.core.UserAccountProvider;
+import com.microsoft.connecteddevices.core.UserAccount;
+import com.microsoft.connecteddevices.core.UserAccountType;
+
+import java.io.IOException;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Map;
+import java.util.UUID;
+
+/**
+ * Sample implementation of UserAccountProvider.
+ * Exposes a single MSA account, that the user logs into via WebView, to CDP.
+ * Follows OAuth2.0 protocol, but automatically refreshes tokens when they are close to expiring.
+ *
+ * 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. signIn() is called (now treated as signing in)
+ * 2. webview 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 signIn() is called.
+ * 4. Now treated as signed in. Account is exposed to CDP. UserAccountChangedEvent 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. signOut() is called
+ * 2. webview 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. UserAccountChangedEvent is fired.
+ */
+@Keep
+public final class MSAAccountProvider implements UserAccountProvider, MSATokenCache.Listener {
+
+ // region Constants
+ private static final String TAG = MSAAccountProvider.class.getName();
+
+ // 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.
+ private static final String[] KNOWN_SCOPES = {
+ "wl.offline_access", // read and update user info at any time
+ "ccs.ReadWrite", // device commanding scope
+ "wns.connect", // push notification scope
+ "https://activity.windows.com/UserActivity.ReadWrite.CreatedByApp", // default useractivities scope
+ };
+
+ // OAuth URLs
+ private static final String REDIRECT_URL = "https://login.live.com/oauth20_desktop.srf";
+ private static final String AUTHORIZE_URL = "https://login.live.com/oauth20_authorize.srf";
+ private static final String LOGOUT_URL = "https://login.live.com/oauth20_logout.srf";
+ // endregion
+
+ // region Member Variables
+ private final String mClientId;
+ private final Map mScopeOverrideMap;
+ private UserAccount mAccount = null;
+ private MSATokenCache mTokenCache;
+ private boolean mSignInSignOutInProgress;
+
+ private final Map> mListenerMap = new ArrayMap<>();
+ private long mNextListenerId = 1L;
+ // endregion
+
+ // region Constructor
+ /**
+ * @param clientId id of the app's registration in the MSA portal
+ * @param scopeOverrides scope overrides for the app
+ * @param context
+ */
+ public MSAAccountProvider(String clientId, final Map scopeOverrides, Context context) {
+ mClientId = clientId;
+ mScopeOverrideMap = scopeOverrides;
+ mTokenCache = new MSATokenCache(clientId, context);
+ mTokenCache.addListener(new MSATokenCache.Listener() {
+ @Override
+ public void onTokenCachePermanentFailure() {
+ onAccessTokenError((mAccount != null ? mAccount.getId() : null), null, true);
+ }
+ });
+
+ if (mTokenCache.loadSavedRefreshToken()) {
+ Log.i(TAG, "Loaded previous session for MSAAccountProvider. Starting as signed in.");
+ mAccount = new UserAccount(UUID.randomUUID().toString(), UserAccountType.MSA);
+ } else {
+ Log.i(TAG, "No previous session could be loaded for MSAAccountProvider. Starting as signed out.");
+ }
+ }
+ // endregion
+
+ // region Private Helpers
+ private List getAuthScopes(final String[] incoming) {
+ ArrayList authScopes = new ArrayList();
+
+ for (String scope : incoming) {
+ if (mScopeOverrideMap.containsKey(scope)) {
+ for (String replacement : mScopeOverrideMap.get(scope)) {
+ authScopes.add(replacement);
+ }
+ } else {
+ authScopes.add(scope);
+ }
+ }
+
+ return authScopes;
+ }
+
+ private AsyncOperation notifyListenersAsync() {
+ return AsyncOperation.supplyAsync(new AsyncOperation.Supplier() {
+ @Override
+ public Void get() {
+ for (EventListener listener : mListenerMap.values()) {
+ listener.onEvent(MSAAccountProvider.this, null);
+ }
+ return null;
+ }
+ });
+ }
+
+ private synchronized void addAccount() {
+ Log.i(TAG, "Adding an account.");
+ mAccount = new UserAccount(UUID.randomUUID().toString(), UserAccountType.MSA);
+ notifyListenersAsync();
+ }
+
+ private synchronized void removeAccount() {
+ if (isSignedIn()) {
+ Log.i(TAG, "Removing account.");
+ mAccount = null;
+ mTokenCache.clearTokens();
+ notifyListenersAsync();
+ }
+ }
+
+ /**
+ * 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.
+ */
+ private AsyncOperation requestNewAccessTokenAsync(final String scope) {
+ // Need the refresh token first, then can use it to request an access token
+ return mTokenCache.getRefreshTokenAsync()
+ .thenComposeAsync(new AsyncOperation.ResultFunction>() {
+ @Override
+ public AsyncOperation apply(String refreshToken) {
+ return MSATokenRequest.requestAsync(mClientId, MSATokenRequest.GrantType.REFRESH, scope, null, refreshToken);
+ }
+ })
+ .thenApplyAsync(new AsyncOperation.ResultFunction() {
+ @Override
+ public AccessTokenResult apply(MSATokenRequest.Result result) throws Throwable {
+ switch (result.getStatus()) {
+ case SUCCESS:
+ Log.i(TAG, "Successfully fetched access token.");
+ mTokenCache.setAccessToken(result.getAccessToken(), scope, result.getExpiresIn());
+ return new AccessTokenResult(AccessTokenRequestStatus.SUCCESS, result.getAccessToken());
+
+ case TRANSIENT_FAILURE:
+ Log.e(TAG, "Requesting new access token failed temporarily, please try again.");
+ return new AccessTokenResult(AccessTokenRequestStatus.TRANSIENT_ERROR, null);
+
+ default: // PERMANENT_FAILURE
+ Log.e(TAG, "Permanent error occurred while fetching access token.");
+ onAccessTokenError(mAccount.getId(), new String[] { scope }, true);
+ throw new IOException("Permanent error occurred while fetching access token.");
+ }
+ }
+ });
+ }
+ // endregion
+
+ public String getClientId() {
+ return mClientId;
+ }
+
+ // region Interactive Sign-in/out
+ public synchronized boolean isSignedIn() {
+ return mAccount != null;
+ }
+
+ /**
+ * 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.
+ */
+ public synchronized AsyncOperation signIn(final Activity currentActivity) throws IllegalStateException {
+ if (isSignedIn() || mSignInSignOutInProgress) {
+ throw new IllegalStateException();
+ }
+
+ final String signInUrl = AUTHORIZE_URL + "?redirect_uri=" + REDIRECT_URL + "&response_type=code&client_id=" + mClientId +
+ "&scope=" + TextUtils.join("+", getAuthScopes(KNOWN_SCOPES));
+ final AsyncOperation authCodeOperation = new AsyncOperation<>();
+ final AsyncOperation signInOperation = new AsyncOperation<>();
+ mSignInSignOutInProgress = true;
+
+ final Dialog dialog = new Dialog(currentActivity);
+ dialog.setContentView(R.layout.auth_dialog);
+ final WebView web = (WebView)dialog.findViewById(R.id.webv);
+ web.setWebChromeClient(new WebChromeClient());
+ web.getSettings().setJavaScriptEnabled(true);
+ web.getSettings().setDomStorageEnabled(true);
+
+ web.loadUrl(signInUrl);
+
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
+ web.setWebViewClient(new WebViewClient() {
+ @Override
+ public void onPageFinished(WebView view, String url) {
+ super.onPageFinished(view, url);
+ onSignInPageFinishedInternal(url, dialog, authCodeOperation, signInOperation);
+ }
+
+ @Override
+ @TargetApi(23)
+ public void onReceivedError(WebView view, WebResourceRequest request, WebResourceError error) {
+ super.onReceivedError(view, request, error);
+ onReceivedSignInErrorInternal(error.getDescription().toString(), authCodeOperation, signInOperation);
+ }
+ });
+ } else {
+ web.setWebViewClient(new WebViewClient() {
+ @Override
+ public void onPageFinished(WebView view, String url) {
+ super.onPageFinished(view, url);
+ onSignInPageFinishedInternal(url, dialog, authCodeOperation, signInOperation);
+ }
+
+ @Override
+ public void onReceivedError(WebView view, int errorCode, String description, String failingUrl) {
+ super.onReceivedError(view, errorCode, description, failingUrl);
+ onReceivedSignInErrorInternal(description, authCodeOperation, signInOperation);
+ }
+ });
+ }
+
+ authCodeOperation // chain after successfully fetching the authcode (does not execute if authCodeOperation completed exceptionally)
+ .thenComposeAsync(new AsyncOperation.ResultFunction>() {
+ @Override
+ public AsyncOperation apply(String authCode) {
+ return MSATokenRequest.requestAsync(mClientId, MSATokenRequest.GrantType.CODE, null, REDIRECT_URL, authCode);
+ }
+ })
+ .thenAcceptAsync(new AsyncOperation.ResultConsumer() {
+ @Override
+ public void accept(MSATokenRequest.Result result) {
+ synchronized (MSAAccountProvider.this) {
+ mSignInSignOutInProgress = false;
+ }
+
+ if (result.getStatus() == MSATokenRequest.Result.Status.SUCCESS) {
+ if (result.getRefreshToken() == null) {
+ Log.e(TAG, "Unexpected: refresh token is null despite succeeding in refresh.");
+ signInOperation.complete(false);
+ }
+
+ Log.i(TAG, "Successfully fetched refresh token.");
+ mTokenCache.setRefreshToken(result.getRefreshToken());
+ addAccount();
+ signInOperation.complete(true);
+
+ } else {
+ Log.e(TAG, "Failed to fetch refresh token using auth code.");
+ signInOperation.complete(false);
+ }
+ }
+ });
+
+ dialog.show();
+ dialog.setCancelable(true);
+
+ return signInOperation;
+ }
+
+ /**
+ * Signs the user out by going through the webview, then clears the cache and current state.
+ */
+ public synchronized void signOut(final Activity currentActivity) throws IllegalStateException {
+ final String signOutUrl = LOGOUT_URL + "?client_id=" + mClientId + "&redirect_uri=" + REDIRECT_URL;
+ mSignInSignOutInProgress = true;
+
+ final Dialog dialog = new Dialog(currentActivity);
+ dialog.setContentView(R.layout.auth_dialog);
+ WebView web = (WebView)dialog.findViewById(R.id.webv);
+
+ web.loadUrl(signOutUrl);
+
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
+ web.setWebViewClient(new WebViewClient() {
+ @Override
+ public void onPageFinished(WebView view, String url) {
+ super.onPageFinished(view, url);
+ onSignOutPageFinishedInternal(url, dialog);
+ }
+
+ @Override
+ @TargetApi(23)
+ public void onReceivedError(WebView view, WebResourceRequest request, WebResourceError error) {
+ super.onReceivedError(view, request, error);
+ onReceivedSignOutErrorInternal(error.getDescription().toString());
+ }
+ });
+ } else {
+ web.setWebViewClient(new WebViewClient() {
+ @Override
+ public void onPageFinished(WebView view, String url) {
+ super.onPageFinished(view, url);
+ onSignOutPageFinishedInternal(url, dialog);
+ }
+
+ @Override
+ public void onReceivedError(WebView view, int errorCode, String description, String failingUrl) {
+ super.onReceivedError(view, errorCode, description, failingUrl);
+ onReceivedSignOutErrorInternal(description);
+ }
+ });
+ }
+ }
+ // endregion
+
+ // region UserAccountProvider Overrides
+ @Override
+ public synchronized UserAccount[] getUserAccounts() {
+ if (mAccount != null) {
+ return new UserAccount[] { mAccount };
+ }
+
+ return new UserAccount[0];
+ }
+
+ @Override
+ public synchronized AsyncOperation getAccessTokenForUserAccountAsync(final String accountId, final String[] scopes) {
+ if (mAccount != null && accountId != null && accountId.equals(mAccount.getId()) && scopes.length > 0) {
+
+ final String scope = TextUtils.join(" ", getAuthScopes(scopes));
+
+ return mTokenCache.getAccessTokenAsync(scope).thenComposeAsync(
+ new AsyncOperation.ResultFunction>() {
+ @Override
+ public AsyncOperation apply(String accessToken) {
+ if (accessToken != null) {
+ // token already exists in the cache, can early return
+ return AsyncOperation.completedFuture(new AccessTokenResult(AccessTokenRequestStatus.SUCCESS, accessToken));
+ } else {
+ // token does not yet exist in the cache, need to request a new one
+ return requestNewAccessTokenAsync(scope);
+ }
+ }
+ });
+ }
+
+ // No access token is available
+ return AsyncOperation.completedFuture(new AccessTokenResult(AccessTokenRequestStatus.TRANSIENT_ERROR, null));
+ }
+
+ @Override
+ public synchronized long addUserAccountChangedListener(EventListener listener) {
+ long id = mNextListenerId++;
+ mListenerMap.put(id, listener);
+ return id;
+ }
+
+ @Override
+ public synchronized void removeUserAccountChangedListener(long id) {
+ mListenerMap.remove(id);
+ }
+
+ @Override
+ public synchronized void onAccessTokenError(String accountId, String[] scopes, boolean isPermanentError) {
+ if (isPermanentError) {
+ removeAccount();
+ } else {
+ mTokenCache.markAllTokensExpired();
+ }
+ }
+ // endregion
+
+ // region MSATokenCacheListener Overrides
+ @Override
+ public void onTokenCachePermanentFailure() {
+ onAccessTokenError(null, null, true);
+ }
+ // endregion
+
+ // region Internal Helpers
+
+ private void onSignInPageFinishedInternal(
+ String url, Dialog dialog, AsyncOperation authCodeOperation, AsyncOperation signInOperation) {
+ if (url.startsWith(REDIRECT_URL)) {
+ final Uri uri = Uri.parse(url);
+ final String code = uri.getQueryParameter("code");
+ final String error = uri.getQueryParameter("error");
+
+ dialog.dismiss();
+
+ if ((error != null) || (code == null) || (code.length() <= 0)) {
+ synchronized (MSAAccountProvider.this) {
+ mSignInSignOutInProgress = false;
+ }
+
+ signInOperation.complete(false);
+ authCodeOperation.completeExceptionally(
+ new Exception((error != null) ? error : "Failed to authenticate with unknown error"));
+ } else {
+ authCodeOperation.complete(code);
+ }
+ }
+ }
+
+ private void onReceivedSignInErrorInternal(
+ String errorString, AsyncOperation authCodeOperation, AsyncOperation signInOperation) {
+ Log.e(TAG, "Encountered web resource loading error while signing in: \'" + errorString + "\'");
+ synchronized (MSAAccountProvider.this) {
+ mSignInSignOutInProgress = false;
+ }
+
+ signInOperation.complete(false);
+ authCodeOperation.completeExceptionally(new Exception(errorString));
+ }
+
+ public void onSignOutPageFinishedInternal(String url, Dialog dialog) {
+ if (!url.contains("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;
+ }
+
+ synchronized (MSAAccountProvider.this) {
+ mSignInSignOutInProgress = false;
+ }
+
+ final Uri uri = Uri.parse(url);
+ final String error = uri.getQueryParameter("error");
+ if (error != null) {
+ Log.e(TAG, "Signed out failed with error: " + error);
+ }
+
+ removeAccount();
+ dialog.dismiss();
+ }
+
+ public void onReceivedSignOutErrorInternal(String errorString) {
+ Log.e(TAG, "Encountered web resource loading error while signing out: \'" + errorString + "\'");
+ synchronized (MSAAccountProvider.this) {
+ mSignInSignOutInProgress = false;
+ }
+ }
+
+ // endregion
+}
diff --git a/Android/samples/graphnotificationssample/sampleaccountproviders/android/src/com/microsoft/connecteddevices/sampleaccountproviders/MSATokenCache.java b/Android/samples/graphnotificationssample/sampleaccountproviders/android/src/com/microsoft/connecteddevices/sampleaccountproviders/MSATokenCache.java
new file mode 100644
index 0000000..2017af9
--- /dev/null
+++ b/Android/samples/graphnotificationssample/sampleaccountproviders/android/src/com/microsoft/connecteddevices/sampleaccountproviders/MSATokenCache.java
@@ -0,0 +1,486 @@
+//
+// Copyright (c) Microsoft Corporation. All rights reserved.
+//
+
+package com.microsoft.connecteddevices.sampleaccountproviders;
+
+import android.content.Context;
+import android.support.annotation.Keep;
+import android.util.ArrayMap;
+import android.util.Log;
+
+import com.microsoft.connecteddevices.base.AsyncOperation;
+
+import java.io.BufferedReader;
+import java.io.BufferedWriter;
+import java.io.File;
+import java.io.FileInputStream;
+import java.io.FileOutputStream;
+import java.io.IOException;
+import java.io.InputStreamReader;
+import java.io.OutputStreamWriter;
+import java.text.DateFormat;
+import java.text.ParseException;
+import java.util.ArrayList;
+import java.util.Calendar;
+import java.util.Collection;
+import java.util.Date;
+import java.util.Map;
+import java.util.Set;
+import java.util.concurrent.Executors;
+import java.util.concurrent.ScheduledExecutorService;
+import java.util.concurrent.TimeUnit;
+
+import org.json.JSONException;
+import org.json.JSONObject;
+
+/**
+ * Caches MSA access and refresh tokens, automatically refreshing them as needed when fetching from the cache.
+ * Cached refresh tokens are persisted across sessions.
+ */
+@Keep
+final class MSATokenCache {
+ private static final String TAG = MSATokenCache.class.getName();
+
+ // Max number of times to try to refresh a token through transient failures
+ private static final int TOKEN_REFRESH_MAX_RETRIES = 3;
+
+ // How quickly to retry refreshing a token when encountering a transient failure
+ private static final long MSA_REFRESH_TOKEN_RETRY_SECONDS = 30 * 60; // 30 minutes
+ private static final long MSA_ACCESS_TOKEN_RETRY_SECONDS = 3 * 60; // 3 minutes
+
+ // How long it takes a refresh token to expire
+ private static final int MSA_REFRESH_TOKEN_EXPIRATION_SECONDS = 10 * 24 * 60 * 60; // 10 days
+
+ // How long before expiry to consider a token in need of a refresh.
+ // (MSA_REFRESH_TOKEN_CLOSE_TO_EXPIRY_SECONDS is intended to be aggressive and keep the refresh token relatively far from expiry)
+ private static final int MSA_REFRESH_TOKEN_CLOSE_TO_EXPIRY_SECONDS = 7 * 24 * 60 * 60; // 7 days
+ private static final int MSA_ACCESS_TOKEN_CLOSE_TO_EXPIRY_SECONDS = 5 * 60; // 5 minutes
+
+ private static final String MSA_OFFLINE_ACCESS_SCOPE = "wl.offline_access";
+
+ private static final ScheduledExecutorService sRetryExecutor = Executors.newSingleThreadScheduledExecutor();
+
+ /**
+ * Helper function. Returns a Date n seconds from now.
+ */
+ private static final Date getDateSecondsAfterNow(int seconds) {
+ return getDateSecondsAfter(null, seconds);
+ }
+
+ /**
+ * Helper function. Returns a Date n seconds after date.
+ */
+ private static final Date getDateSecondsAfter(Date date, int seconds) {
+ Calendar calendar = Calendar.getInstance(); // sets time to current
+ if (date != null) {
+ calendar.setTime(date);
+ }
+ calendar.add(Calendar.SECOND, seconds);
+ return calendar.getTime();
+ }
+
+ /**
+ * Private helper class wrapping a cached access token. Responsible for refreshing it on-demand.
+ */
+ private class MSATokenCacheItem {
+ protected String mToken;
+ protected Date mCloseToExpirationDate; // Actual expiration date is used less than this, so just cache this instead
+ protected final MSATokenRequest mRefreshRequest;
+
+ public MSATokenCacheItem(String token, int expiresInSeconds, MSATokenRequest refreshRequest) {
+ mToken = token;
+ mCloseToExpirationDate = getDateSecondsAfterNow(expiresInSeconds - getCloseToExpirySeconds());
+ mRefreshRequest = refreshRequest;
+ }
+
+ /**
+ * Returns the number of seconds before expiry that this token is considered in need of a refresh.
+ */
+ protected int getCloseToExpirySeconds() {
+ return MSA_ACCESS_TOKEN_CLOSE_TO_EXPIRY_SECONDS; // Base class expects access tokens
+ }
+
+ /**
+ * Returns the number of seconds to wait before retrying, when a refresh fails with a transient error.
+ */
+ protected long getRetrySeconds() {
+ return MSA_ACCESS_TOKEN_RETRY_SECONDS; // Base class expects access tokens
+ }
+
+ /**
+ * Returns the refresh token to use to refresh the token held by this item.
+ * For access tokens, this gets the refresh token held by the cache.
+ * For refresh tokens, this just returns the currently-held token.
+ */
+ protected AsyncOperation getRefreshTokenAsync() {
+ return MSATokenCache.this.getRefreshTokenAsync();
+ }
+
+ /**
+ * Steps to complete after a successful refresh.
+ * For access tokens, sets the new token and new expiration.
+ * For refresh tokens, marks current access tokens as expired, and caches the refresh token in persistent storage.
+ */
+ protected synchronized void onSuccessfulRefresh(MSATokenRequest.Result result) {
+ Log.i(TAG, "Successfully refreshed access token.");
+ mToken = result.getAccessToken();
+ mCloseToExpirationDate = getDateSecondsAfterNow(result.getExpiresIn() - getCloseToExpirySeconds());
+ }
+
+ /**
+ * Private helper - asynchronously fetches the token held by this item.
+ * If the token is close to expiry, refreshes it first.
+ * If this refresh fails due to transient error, recursively retries up to remainingRetries times to refresh.
+ *
+ * @param operation AsyncOperation to return the token on
+ * @param remainingRetries number of times to retry refreshing, in the case of transient error
+ * @return the operation that was passed in
+ */
+ private AsyncOperation _getTokenAsyncInternal(final AsyncOperation operation, final int remainingRetries) {
+ if (!needsRefresh()) {
+ operation.complete(mToken); // Already have a non-stale token, can just return with it
+ return operation;
+ }
+
+ getRefreshTokenAsync()
+ .thenComposeAsync(new AsyncOperation.ResultFunction>() {
+ @Override
+ public AsyncOperation apply(String refreshToken) {
+ return mRefreshRequest.requestAsync(refreshToken);
+ }
+ })
+ .thenAcceptAsync(new AsyncOperation.ResultConsumer() {
+ @Override
+ public void accept(MSATokenRequest.Result result) {
+ switch (result.getStatus()) {
+ case SUCCESS:
+ onSuccessfulRefresh(result);
+ operation.complete(mToken);
+ break;
+
+ case TRANSIENT_FAILURE:
+ // Recursively retry the refresh, if there are still remaining retries
+ if (remainingRetries <= 0) {
+ Log.e(TAG, "Reached max number of retries for refreshing token.");
+ operation.complete(null);
+
+ } else {
+ Log.i(TAG, "Transient error while refreshing token, retrying in " + getRetrySeconds() + "seconds...");
+ sRetryExecutor.schedule(new Runnable() {
+ @Override
+ public void run() {
+ _getTokenAsyncInternal(operation, remainingRetries - 1);
+ }
+ }, getRetrySeconds(), TimeUnit.SECONDS);
+ }
+ break;
+
+ default: // PERMANENT_FAILURE
+ Log.e(TAG, "Permanent error occurred while refreshing token.");
+ MSATokenCache.this.onPermanentFailure();
+ operation.complete(null);
+ break;
+ }
+ }
+ });
+
+ return operation;
+ }
+
+ /**
+ * Asynchronously fetches the token held by this item, refreshing it if necessary.
+ */
+ public AsyncOperation getTokenAsync() {
+ AsyncOperation ret = new AsyncOperation();
+ return _getTokenAsyncInternal(ret, TOKEN_REFRESH_MAX_RETRIES);
+ }
+
+ public boolean needsRefresh() {
+ return mCloseToExpirationDate.before(new Date());
+ }
+
+ public boolean isExpired() {
+ return getDateSecondsAfter(mCloseToExpirationDate, getCloseToExpirySeconds()).before(new Date());
+ }
+
+ public synchronized void markExpired() {
+ mCloseToExpirationDate = new Date(0); // Start of epoch
+ }
+ }
+
+ /**
+ * Private helper class wrapping a cached refresh token. Responsible for refreshing it on demand. Can translate to/from json format.
+ */
+ private final class MSARefreshTokenCacheItem extends MSATokenCacheItem {
+ private static final String JSON_TOKEN_KEY = "refresh_token";
+ private static final String JSON_EXPIRATION_KEY = "expires";
+
+ public MSARefreshTokenCacheItem(String token, int expiresInSeconds, MSATokenRequest refreshRequest) {
+ super(token, expiresInSeconds, refreshRequest);
+ }
+
+ public MSARefreshTokenCacheItem(JSONObject json) throws IOException, JSONException, ParseException {
+ super(null, 0, new MSATokenRequest(mClientId, MSATokenRequest.GrantType.REFRESH, MSA_OFFLINE_ACCESS_SCOPE, null));
+
+ mToken = json.optString(JSON_TOKEN_KEY);
+ String dateString = json.optString(JSON_EXPIRATION_KEY);
+ if (mToken == null || dateString == null) {
+ throw new IOException("Saved refresh token was improperly formatted.");
+ }
+
+ Date expirationDate = DateFormat.getDateTimeInstance().parse(dateString);
+ mCloseToExpirationDate = getDateSecondsAfter(expirationDate, -MSA_REFRESH_TOKEN_CLOSE_TO_EXPIRY_SECONDS);
+ }
+
+ protected int getCloseToExpirySeconds() {
+ return MSA_REFRESH_TOKEN_CLOSE_TO_EXPIRY_SECONDS;
+ }
+
+ protected long getRetrySeconds() {
+ return MSA_REFRESH_TOKEN_RETRY_SECONDS;
+ }
+
+ protected AsyncOperation getRefreshTokenAsync() {
+ return AsyncOperation.completedFuture(mToken);
+ }
+
+ protected synchronized void onSuccessfulRefresh(MSATokenRequest.Result result) {
+ Log.i(TAG, "Successfully refreshed refresh token.");
+ mToken = result.getRefreshToken();
+ mCloseToExpirationDate =
+ getDateSecondsAfterNow(MSA_REFRESH_TOKEN_EXPIRATION_SECONDS - MSA_REFRESH_TOKEN_CLOSE_TO_EXPIRY_SECONDS);
+ MSATokenCache.this.markAccessTokensExpired();
+ MSATokenCache.this.trySaveRefreshToken();
+ }
+
+ public synchronized JSONObject toJSON() throws JSONException {
+ // Get the actual expiration date
+ Date expirationDate = getDateSecondsAfter(mCloseToExpirationDate, MSA_REFRESH_TOKEN_CLOSE_TO_EXPIRY_SECONDS);
+
+ JSONObject ret = new JSONObject();
+ ret.put(JSON_TOKEN_KEY, mToken);
+ ret.put(JSON_EXPIRATION_KEY, DateFormat.getDateTimeInstance().format(expirationDate));
+ return ret;
+ }
+ }
+
+ /**
+ * Provides callbacks when the cache encounters a permanent failure and has to wipe its state.
+ */
+ public static interface Listener { void onTokenCachePermanentFailure(); }
+
+ private final String mClientId;
+ private final Context mContext;
+
+ private MSARefreshTokenCacheItem mCachedRefreshToken = null;
+ private final Map mCachedAccessTokens = new ArrayMap<>();
+
+ private final Collection mListeners = new ArrayList<>();
+
+ public MSATokenCache(String clientId, Context context) {
+ mClientId = clientId;
+ mContext = context;
+ }
+
+ /**
+ * Returns a file in application-specific storage that's used to persist the refresh token across sessions.
+ */
+ private File getRefreshTokenSaveFile() throws IOException {
+ Context appContext = mContext.getApplicationContext();
+ File appDirectory = appContext.getDir(appContext.getPackageName(), Context.MODE_PRIVATE);
+ if (appDirectory == null) {
+ throw new IOException("Could not access app directory.");
+ }
+
+ return new File(appDirectory, "samplemsaaccountprovider.dat");
+ }
+
+ /**
+ * Tries to save the current refresh token to persistent storage.
+ */
+ private void trySaveRefreshToken() {
+ Log.i(TAG, "Trying to save refresh token...");
+ try {
+ File file = getRefreshTokenSaveFile();
+ JSONObject json = file.exists() ? new JSONObject(IOUtil.readUTF8Stream(new FileInputStream(file))) : new JSONObject();
+
+ json.put(mClientId, mCachedRefreshToken.toJSON());
+ IOUtil.writeUTF8Stream(new FileOutputStream(file), json.toString());
+
+ Log.i(TAG, "Saved refresh token.");
+
+ } catch (IOException | JSONException e) {
+ Log.e(TAG, "Exception while saving refresh token. \"" + e.getLocalizedMessage() + "\" Will not save.");
+ }
+ }
+
+ /**
+ * Tries to read a saved refresh token from persistent storage, and return it as an MSARefreshTokenItem.
+ */
+ private MSARefreshTokenCacheItem tryReadSavedRefreshToken() {
+ Log.i(TAG, "Trying to read saved refresh token...");
+ try {
+ File file = getRefreshTokenSaveFile();
+
+ if (!file.exists()) {
+ Log.i(TAG, "No saved refresh token was found.");
+ return null;
+ }
+
+ JSONObject json = new JSONObject(IOUtil.readUTF8Stream(new FileInputStream(file)));
+ JSONObject innerJson = json.optJSONObject(mClientId);
+
+ if (innerJson == null) {
+ Log.i(TAG, "Could not read saved refresh token.");
+ return null;
+ }
+
+ Log.i(TAG, "Read saved refresh token.");
+ return new MSARefreshTokenCacheItem(innerJson);
+
+ } catch (IOException | JSONException | ParseException e) {
+ Log.e(TAG, "Exception reading saved refresh token. \"" + e.getLocalizedMessage() + "\"");
+ return null;
+ }
+ }
+
+ /**
+ * Tries to delete the saved refresh token for this app in persistent storage.
+ */
+ private void tryClearSavedRefreshToken() {
+ Log.i(TAG, "Trying to delete saved refresh token...");
+ try {
+ File file = getRefreshTokenSaveFile();
+ if (!file.exists()) {
+ Log.i(TAG, "No saved refresh token was found.");
+ return;
+ }
+
+ try {
+ // Try to remove just a section of the json corresponding to client id
+ JSONObject json = new JSONObject(IOUtil.readUTF8Stream(new FileInputStream(file)));
+ json.remove(mClientId);
+
+ if (json.length() <= 0) {
+ file.delete(); // Just delete the file if the json would be empty
+ } else {
+ IOUtil.writeUTF8Stream(new FileOutputStream(file), json.toString());
+ }
+ } catch (JSONException e) {
+ // Failed to parse the json, just delete everything
+ file.delete();
+ }
+
+ Log.i(TAG, "Deleted saved refresh token.");
+
+ } catch (IOException e) { Log.e(TAG, "Failed to delete saved refresh token. \"" + e.getLocalizedMessage() + "\""); }
+ }
+
+ /**
+ * Marks access tokens as expired, such that a refresh is performed before returning, when the access token is next requested.
+ */
+ private synchronized void markAccessTokensExpired() {
+ for (MSATokenCacheItem cachedAccessToken : mCachedAccessTokens.values()) {
+ cachedAccessToken.markExpired();
+ }
+ }
+
+ /**
+ * Calls back any listeners that the cache has encountered a permanent failure, and that they should perform any needed error-handling.
+ */
+ private void onPermanentFailure() {
+ clearTokens();
+ for (Listener listener : mListeners) {
+ listener.onTokenCachePermanentFailure();
+ }
+ }
+
+ public void setRefreshToken(String refreshToken) {
+ MSATokenRequest refreshRequest = new MSATokenRequest(mClientId, MSATokenRequest.GrantType.REFRESH, MSA_OFFLINE_ACCESS_SCOPE, null);
+
+ synchronized (this) {
+ mCachedRefreshToken = new MSARefreshTokenCacheItem(refreshToken, MSA_REFRESH_TOKEN_EXPIRATION_SECONDS, refreshRequest);
+ markAccessTokensExpired();
+ trySaveRefreshToken();
+ }
+ }
+
+ public void setAccessToken(String accessToken, String scope, int expiresInSeconds) {
+ MSATokenRequest refreshRequest = new MSATokenRequest(mClientId, MSATokenRequest.GrantType.REFRESH, scope, null);
+
+ synchronized (this) {
+ mCachedAccessTokens.put(scope, new MSATokenCacheItem(accessToken, expiresInSeconds, refreshRequest));
+ }
+ }
+
+ public synchronized AsyncOperation getRefreshTokenAsync() {
+ if (mCachedRefreshToken != null) {
+ return mCachedRefreshToken.getTokenAsync();
+ } else {
+ return AsyncOperation.completedFuture(null);
+ }
+ }
+
+ public synchronized AsyncOperation getAccessTokenAsync(String scope) {
+ MSATokenCacheItem cachedAccessToken = mCachedAccessTokens.get(scope);
+ if (cachedAccessToken != null) {
+ return cachedAccessToken.getTokenAsync();
+ } else {
+ return AsyncOperation.completedFuture(null);
+ }
+ }
+
+ public synchronized void addListener(Listener listener) {
+ mListeners.add(listener);
+ }
+
+ public synchronized void removeListener(Listener listener) {
+ mListeners.remove(listener);
+ }
+
+ public Set allScopes() {
+ return mCachedAccessTokens.keySet();
+ }
+
+ /**
+ * Tries to load a saved refresh token from disk. If successful, the loaded refresh token is used as this cache's refresh token.
+ * @return Whether a saved refresh token was loaded successfully.
+ */
+ public boolean loadSavedRefreshToken() {
+ Log.i(TAG, "Trying to load saved refresh token...");
+ MSARefreshTokenCacheItem savedRefreshToken = tryReadSavedRefreshToken();
+
+ if (savedRefreshToken == null) {
+ Log.i(TAG, "Failed to load saved refresh token.");
+ return false;
+ }
+
+ if (savedRefreshToken.isExpired()) {
+ Log.i(TAG, "Read saved refresh token, but was expired. Ignoring.");
+ return false;
+ }
+
+ Log.i(TAG, "Successfully loaded saved refresh token.");
+ mCachedRefreshToken = savedRefreshToken;
+ markAllTokensExpired(); // Force a refresh on everything on first use
+ return true;
+ }
+
+ /**
+ * Clears all tokens from the cache, and any saved refresh tokens belonging to this app in persistent storage.
+ */
+ public synchronized void clearTokens() {
+ mCachedAccessTokens.clear();
+ mCachedRefreshToken = null;
+ tryClearSavedRefreshToken();
+ }
+
+ /**
+ * Marks all tokens as expired, such that a refresh is performed before returning, when a token is next requested.
+ */
+ public synchronized void markAllTokensExpired() {
+ mCachedRefreshToken.markExpired();
+ markAccessTokensExpired();
+ }
+}
\ No newline at end of file
diff --git a/Android/samples/graphnotificationssample/sampleaccountproviders/android/src/com/microsoft/connecteddevices/sampleaccountproviders/MSATokenRequest.java b/Android/samples/graphnotificationssample/sampleaccountproviders/android/src/com/microsoft/connecteddevices/sampleaccountproviders/MSATokenRequest.java
new file mode 100644
index 0000000..08eb010
--- /dev/null
+++ b/Android/samples/graphnotificationssample/sampleaccountproviders/android/src/com/microsoft/connecteddevices/sampleaccountproviders/MSATokenRequest.java
@@ -0,0 +1,207 @@
+//
+// Copyright (c) Microsoft Corporation. All rights reserved.
+//
+
+package com.microsoft.connecteddevices.sampleaccountproviders;
+
+import android.support.annotation.Keep;
+import android.util.Log;
+import android.util.Pair;
+
+import com.microsoft.connecteddevices.base.AsyncOperation;
+
+import java.io.BufferedReader;
+import java.io.BufferedWriter;
+import java.io.InputStream;
+import java.io.InputStreamReader;
+import java.io.IOException;
+import java.io.OutputStream;
+import java.io.OutputStreamWriter;
+import java.io.UnsupportedEncodingException;
+import java.net.HttpURLConnection;
+import java.net.URL;
+import java.net.URLEncoder;
+import java.util.LinkedList;
+import java.util.List;
+import java.util.Map;
+
+import javax.net.ssl.HttpsURLConnection;
+
+import org.json.JSONException;
+import org.json.JSONObject;
+
+/**
+ * Encapsulates a noninteractive request for an MSA token.
+ * This request may be performed multiple times.
+ */
+@Keep
+final class MSATokenRequest {
+
+ private static final String TAG = MSATokenRequest.class.getName();
+
+ // OAuth Token Grant Type
+ public static final class GrantType {
+ public static final String CODE = "authorization_code";
+ public static final String REFRESH = "refresh_token";
+ }
+
+ /**
+ * Class encapsulating the result of an MSATokenRequest.
+ */
+ public static final class Result {
+ public static enum Status { SUCCESS, TRANSIENT_FAILURE, PERMANENT_FAILURE }
+
+ private final Status mStatus;
+ private String mAccessToken = null;
+ private String mRefreshToken = null;
+ private int mExpiresIn = 0;
+
+ Result(Status status, JSONObject responseJson) {
+ mStatus = status;
+
+ if (responseJson != null) {
+ mAccessToken = responseJson.optString("access_token", null);
+ mRefreshToken = responseJson.optString("refresh_token", null);
+ mExpiresIn = responseJson.optInt("expires_in"); // returns 0 if this key doesn't exist
+ }
+ }
+
+ public Status getStatus() {
+ return mStatus;
+ }
+
+ public String getAccessToken() {
+ return mAccessToken;
+ }
+
+ public String getRefreshToken() {
+ return mRefreshToken;
+ }
+
+ public int getExpiresIn() {
+ return mExpiresIn;
+ }
+ }
+
+ private final String mClientId;
+ private final String mGrantType;
+ private final String mScope;
+ private final String mRedirectUri;
+
+ public MSATokenRequest(String clientId, String grantType, String scope, String redirectUri) {
+ mClientId = clientId;
+ mGrantType = grantType;
+ mScope = scope;
+ mRedirectUri = redirectUri;
+ }
+
+ /**
+ * Builds a query string from a list of name-value pairs.
+ *
+ * @param params Name-value pairs to compose the query string from
+ * @return A query string composed of the provided name-value pairs
+ * @throws UnsupportedEncodingException Thrown if encoding a name or value fails
+ */
+ private static String getQueryString(List> params) throws UnsupportedEncodingException {
+ StringBuilder queryStringBuilder = new StringBuilder();
+ boolean isFirstParam = true;
+ for (Pair param : params) {
+ if (isFirstParam) {
+ isFirstParam = false;
+ } else {
+ queryStringBuilder.append("&");
+ }
+
+ queryStringBuilder.append(URLEncoder.encode(param.first, "UTF-8"));
+ queryStringBuilder.append("=");
+ queryStringBuilder.append(URLEncoder.encode(param.second, "UTF-8"));
+ }
+
+ return queryStringBuilder.toString();
+ }
+
+ /**
+ * Fetch Token (Access or Refresh Token).
+ * @param clientId - clientId of the app's registration in the MSA portal
+ * @param grantType - one of the MSATokenRequest.GrantType constants
+ * @param scope
+ * @param redirectUri
+ * @param token - authCode for GrantType.CODE, or refresh token for GrantType.REFRESH
+ */
+ public static AsyncOperation requestAsync(
+ final String clientId, final String grantType, final String scope, final String redirectUri, final String token) {
+ if (token == null || token.length() <= 0) {
+ Log.e(TAG, "Refresh token or auth code for MSATokenRequest was unexpectedly empty - treating as permanent failure.");
+ return AsyncOperation.completedFuture(new MSATokenRequest.Result(Result.Status.PERMANENT_FAILURE, null));
+ }
+
+ return AsyncOperation.supplyAsync(new AsyncOperation.Supplier() {
+ @Override
+ public MSATokenRequest.Result get() {
+ HttpsURLConnection connection = null;
+ MSATokenRequest.Result.Status status = Result.Status.TRANSIENT_FAILURE;
+ JSONObject responseJson = null;
+
+ try {
+ // Build the query string
+ List> params = new LinkedList<>();
+ params.add(new Pair<>("client_id", clientId));
+ params.add(new Pair<>("grant_type", grantType));
+
+ if (grantType.equals(GrantType.CODE)) {
+ params.add(new Pair<>("redirect_uri", redirectUri));
+ params.add(new Pair<>("code", token));
+ } else if (grantType.equals(GrantType.REFRESH)) {
+ params.add(new Pair<>("scope", scope));
+ params.add(new Pair<>(grantType, token));
+ }
+
+ String queryString = getQueryString(params);
+
+ // Write the query string
+ URL url = new URL("https://login.live.com/oauth20_token.srf");
+ connection = (HttpsURLConnection)url.openConnection();
+ connection.setDoOutput(true);
+ connection.setRequestProperty("Content-Type", "application/x-www-form-urlencoded");
+ IOUtil.writeUTF8Stream(connection.getOutputStream(), queryString);
+
+ // Parse the response
+ int responseCode = connection.getResponseCode();
+ if (responseCode >= 500) {
+ status = Result.Status.TRANSIENT_FAILURE;
+ } else if (responseCode >= 400) {
+ status = Result.Status.PERMANENT_FAILURE;
+ } else if ((responseCode >= 200 && responseCode < 300) || responseCode == 304) {
+ status = Result.Status.SUCCESS;
+ } else {
+ status = Result.Status.TRANSIENT_FAILURE;
+ }
+
+ if (status == Result.Status.SUCCESS) {
+ responseJson = new JSONObject(IOUtil.readUTF8Stream(connection.getInputStream()));
+ } else {
+ Map> heads = connection.getHeaderFields();
+ String error = IOUtil.readUTF8Stream(connection.getErrorStream());
+ Log.e(TAG, "Failed to get token with HTTP code: " + responseCode + heads + error);
+ }
+
+ } catch (IOException | JSONException e) {
+ Log.e(TAG, "Failed to get token: \"" + e.getLocalizedMessage() + "\"");
+ } finally {
+ if (connection != null) {
+ connection.disconnect();
+ }
+ return new MSATokenRequest.Result(status, responseJson);
+ }
+ }
+ });
+ }
+
+ /**
+ * Fetch token (Access or Refresh Token).
+ * @param token - authCode for GrantType.CODE, or refresh token for GrantType.REFRESH
+ */
+ public AsyncOperation requestAsync(String token) {
+ return requestAsync(mClientId, mGrantType, mScope, mRedirectUri, token);
+ }
+}
\ No newline at end of file
diff --git a/Android/samples/graphnotificationssample/sampleaccountproviders/android/src/main/res/layout/auth_dialog.xml b/Android/samples/graphnotificationssample/sampleaccountproviders/android/src/main/res/layout/auth_dialog.xml
new file mode 100644
index 0000000..8039309
--- /dev/null
+++ b/Android/samples/graphnotificationssample/sampleaccountproviders/android/src/main/res/layout/auth_dialog.xml
@@ -0,0 +1,11 @@
+
+
+
+
+
\ No newline at end of file
diff --git a/Android/samples/graphnotificationssample/settings.gradle b/Android/samples/graphnotificationssample/settings.gradle
new file mode 100644
index 0000000..b9e85dc
--- /dev/null
+++ b/Android/samples/graphnotificationssample/settings.gradle
@@ -0,0 +1,4 @@
+include ':app',
+ ':sampleaccountproviders'
+
+project(':sampleaccountproviders').projectDir = new File('sampleaccountproviders/android')
diff --git a/Windows/samples/GraphNotificationsSample/AccountsPage.xaml b/Windows/samples/GraphNotificationsSample/AccountsPage.xaml
new file mode 100644
index 0000000..de91f3a
--- /dev/null
+++ b/Windows/samples/GraphNotificationsSample/AccountsPage.xaml
@@ -0,0 +1,44 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Please login with MSA or AAD account
+
+
+
+
+
+
+
+
+
+
+
diff --git a/Windows/samples/GraphNotificationsSample/AccountsPage.xaml.cs b/Windows/samples/GraphNotificationsSample/AccountsPage.xaml.cs
new file mode 100644
index 0000000..846acc6
--- /dev/null
+++ b/Windows/samples/GraphNotificationsSample/AccountsPage.xaml.cs
@@ -0,0 +1,105 @@
+//*********************************************************
+//
+// Copyright (c) Microsoft. All rights reserved.
+// This code is licensed under the Microsoft Public License.
+// THIS CODE IS PROVIDED *AS IS* WITHOUT WARRANTY OF
+// ANY KIND, EITHER EXPRESS OR IMPLIED, INCLUDING ANY
+// IMPLIED WARRANTIES OF FITNESS FOR A PARTICULAR
+// PURPOSE, MERCHANTABILITY, OR NON-INFRINGEMENT.
+//
+//*********************************************************
+
+using Windows.UI.Xaml;
+using Windows.UI.Xaml.Controls;
+using Windows.UI.Xaml.Navigation;
+
+namespace SDKTemplate
+{
+ public sealed partial class AccountsPage : Page
+ {
+ private MainPage rootPage;
+ private MicrosoftAccountProvider accountProvider;
+
+ public AccountsPage()
+ {
+ this.InitializeComponent();
+ }
+
+ protected override void OnNavigatedTo(NavigationEventArgs e)
+ {
+ rootPage = MainPage.Current;
+ accountProvider = ((App)Application.Current).AccountProvider;
+ UpdateUI();
+ }
+
+ private void UpdateUI()
+ {
+ MsaButton.IsEnabled = (accountProvider.SignedInAccount == null);
+ AadButton.IsEnabled = (accountProvider.SignedInAccount == null);
+ LogoutButton.IsEnabled = (accountProvider.SignedInAccount != null);
+ LogoutButton.Content = "Logout";
+
+ if (accountProvider.SignedInAccount != null)
+ {
+ Description.Text = $"{accountProvider.SignedInAccount.Type} user ";
+ if (accountProvider.AadUser != null)
+ {
+ Description.Text += accountProvider.AadUser.DisplayableId;
+ }
+
+ LogoutButton.Content = $"Logout - {accountProvider.SignedInAccount.Type}";
+ }
+ }
+
+ private async void Button_LoginMSA(object sender, RoutedEventArgs e)
+ {
+ if (accountProvider.SignedInAccount == null)
+ {
+ ((Button)sender).IsEnabled = false;
+
+ bool success = await accountProvider.SignInMsa();
+ if (!success)
+ {
+ rootPage.NotifyUser("MSA login failed!", NotifyType.ErrorMessage);
+ }
+ else
+ {
+ rootPage.NotifyUser("MSA login successful", NotifyType.StatusMessage);
+ }
+
+ UpdateUI();
+ }
+ }
+
+ private async void Button_LoginAAD(object sender, RoutedEventArgs e)
+ {
+ if (accountProvider.SignedInAccount == null)
+ {
+ ((Button)sender).IsEnabled = false;
+
+ bool success = await accountProvider.SignInAad();
+ if (!success)
+ {
+ rootPage.NotifyUser("AAD login failed!", NotifyType.ErrorMessage);
+ }
+ else
+ {
+ rootPage.NotifyUser("AAD login successful", NotifyType.StatusMessage);
+ }
+
+ UpdateUI();
+ }
+ }
+
+ private async void Button_Logout(object sender, RoutedEventArgs e)
+ {
+ ((Button)sender).IsEnabled = false;
+
+ accountProvider.SignOut();
+
+ rootPage.NotifyUser("Logout successful", NotifyType.ErrorMessage);
+
+ UpdateUI();
+ }
+ }
+}
diff --git a/Windows/samples/GraphNotificationsSample/App.xaml b/Windows/samples/GraphNotificationsSample/App.xaml
new file mode 100644
index 0000000..63e80b8
--- /dev/null
+++ b/Windows/samples/GraphNotificationsSample/App.xaml
@@ -0,0 +1,34 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/Windows/samples/GraphNotificationsSample/App.xaml.cs b/Windows/samples/GraphNotificationsSample/App.xaml.cs
new file mode 100644
index 0000000..7d61768
--- /dev/null
+++ b/Windows/samples/GraphNotificationsSample/App.xaml.cs
@@ -0,0 +1,177 @@
+//*********************************************************
+//
+// Copyright (c) Microsoft. All rights reserved.
+// This code is licensed under the MIT License (MIT).
+// THIS CODE IS PROVIDED *AS IS* WITHOUT WARRANTY OF
+// ANY KIND, EITHER EXPRESS OR IMPLIED, INCLUDING ANY
+// IMPLIED WARRANTIES OF FITNESS FOR A PARTICULAR
+// PURPOSE, MERCHANTABILITY, OR NON-INFRINGEMENT.
+//
+//*********************************************************
+
+using Newtonsoft.Json;
+using System;
+using System.Threading.Tasks;
+using Windows.ApplicationModel.Activation;
+using Windows.ApplicationModel.Background;
+using Windows.Networking.PushNotifications;
+using Windows.UI.Xaml;
+using Windows.UI.Xaml.Controls;
+using Windows.UI.Xaml.Navigation;
+
+// The Blank Application template is documented at http://go.microsoft.com/fwlink/?LinkId=402347&clcid=0x409
+
+namespace SDKTemplate
+{
+ public class AppLauchArgs
+ {
+ public string type { get; set; }
+ public string notificationId { get; set; }
+ }
+
+ ///
+ /// Provides application-specific behavior to supplement the default Application class.
+ ///
+ sealed partial class App : Application
+ {
+ public PushNotificationChannel PushChannel { get; set; }
+ public MicrosoftAccountProvider AccountProvider { get; set; }
+ public GraphNotificationProvider NotificationProvider { get; set; }
+
+ ///
+ /// Initializes the singleton application object. This is the first line of authored code
+ /// executed, and as such is the logical equivalent of main() or WinMain().
+ ///
+ public App()
+ {
+ this.InitializeComponent();
+ }
+
+ ///
+ /// Invoked when the application is launched normally by the end user. Other entry points
+ /// will be used such as when the application is launched to open a specific file.
+ ///
+ /// Details about the launch request and process.
+ protected override async void OnLaunched(LaunchActivatedEventArgs e)
+ {
+
+#if DEBUG
+ if (System.Diagnostics.Debugger.IsAttached)
+ {
+ this.DebugSettings.EnableFrameRateCounter = false;
+ }
+#endif
+ Logger.Instance.LogMessage($"App Launched with {e.Arguments}");
+
+ if (PushChannel == null)
+ {
+ PushChannel = await PushNotificationChannelManager.CreatePushNotificationChannelForApplicationAsync();
+ Logger.Instance.LogMessage($"Setup Push {PushChannel.Uri}");
+ PushChannel.PushNotificationReceived += PushNotificationReceived;
+ }
+
+ if (NotificationProvider == null)
+ {
+ Logger.Instance.LogMessage($"Setup AccountsProvider and NotificationsProvider");
+ AccountProvider = new MicrosoftAccountProvider();
+ NotificationProvider = new GraphNotificationProvider(AccountProvider, PushChannel.Uri);
+ }
+
+ Frame rootFrame = Window.Current.Content as Frame;
+
+ // Do not repeat app initialization when the Window already has content,
+ // just ensure that the window is active
+ if (rootFrame == null)
+ {
+ // Create a Frame to act as the navigation context and navigate to the first page
+ rootFrame = new Frame
+ {
+ // Set the default language
+ Language = Windows.Globalization.ApplicationLanguages.Languages[0]
+ };
+
+ rootFrame.NavigationFailed += OnNavigationFailed;
+
+ if (e.PreviousExecutionState == ApplicationExecutionState.Terminated)
+ {
+ // Load state from previously suspended application
+ }
+
+ // Place the frame in the current Window
+ Window.Current.Content = rootFrame;
+ }
+
+ if (rootFrame.Content == null)
+ {
+ // When the navigation stack isn't restored navigate to the first page,
+ // configuring the new page by passing required information as a navigation
+ // parameter
+ rootFrame.Navigate(typeof(MainPage), e.Arguments);
+ }
+
+
+ if (!string.IsNullOrEmpty(e.Arguments))
+ {
+ var result = JsonConvert.DeserializeObject(e.Arguments);
+ NotificationProvider?.Refresh();
+ NotificationProvider?.Activate(result.notificationId, false);
+ }
+
+ // Ensure the current window is active
+ Window.Current.Activate();
+ }
+
+ ///
+ /// Invoked when Navigation to a certain page fails
+ ///
+ /// The Frame which failed navigation
+ /// Details about the navigation failure
+ void OnNavigationFailed(object sender, NavigationFailedEventArgs e)
+ {
+ throw new Exception("Failed to load Page " + e.SourcePageType.FullName);
+ }
+
+ private void PushNotificationReceived(PushNotificationChannel sender, PushNotificationReceivedEventArgs e)
+ {
+ Logger.Instance.LogMessage($"Push received type:{e.NotificationType}");
+ if (e.NotificationType == PushNotificationType.Raw)
+ {
+ e.Cancel = true;
+ NotificationProvider?.ReceiveNotification(e.RawNotification.Content);
+ }
+ }
+
+ private BackgroundTaskDeferral m_deferral;
+ protected override async void OnBackgroundActivated(BackgroundActivatedEventArgs args)
+ {
+ base.OnBackgroundActivated(args);
+ m_deferral = args.TaskInstance.GetDeferral();
+ args.TaskInstance.Canceled += (s, r) =>
+ {
+ Logger.Instance.LogMessage($"Task canceled for {r}");
+ m_deferral.Complete();
+ };
+ Logger.Instance.LogMessage($"{args.TaskInstance.Task.Name} activated in background");
+
+ if (args.TaskInstance.TriggerDetails is RawNotification)
+ {
+ var notification = args.TaskInstance.TriggerDetails as RawNotification;
+ Logger.Instance.LogMessage($"RawNotification received {notification.Content}");
+
+ await Task.Run(() =>
+ {
+ if (NotificationProvider != null)
+ {
+ AccountProvider = new MicrosoftAccountProvider();
+ NotificationProvider = new GraphNotificationProvider(AccountProvider, "");
+ }
+
+ NotificationProvider.ReceiveNotification(notification.Content);
+ });
+ }
+
+ await Task.Delay(TimeSpan.FromSeconds(30));
+ Logger.Instance.LogMessage($"Task completed");
+ }
+ }
+}
diff --git a/Windows/samples/GraphNotificationsSample/Assets/microsoft-sdk.png b/Windows/samples/GraphNotificationsSample/Assets/microsoft-sdk.png
new file mode 100644
index 0000000..380a010
Binary files /dev/null and b/Windows/samples/GraphNotificationsSample/Assets/microsoft-sdk.png differ
diff --git a/Windows/samples/GraphNotificationsSample/Assets/placeholder-sdk.png b/Windows/samples/GraphNotificationsSample/Assets/placeholder-sdk.png
new file mode 100644
index 0000000..01b3138
Binary files /dev/null and b/Windows/samples/GraphNotificationsSample/Assets/placeholder-sdk.png differ
diff --git a/Windows/samples/GraphNotificationsSample/Assets/placeholder.png b/Windows/samples/GraphNotificationsSample/Assets/placeholder.png
new file mode 100644
index 0000000..e2d8381
Binary files /dev/null and b/Windows/samples/GraphNotificationsSample/Assets/placeholder.png differ
diff --git a/Windows/samples/GraphNotificationsSample/Assets/smalltile-sdk.png b/Windows/samples/GraphNotificationsSample/Assets/smalltile-sdk.png
new file mode 100644
index 0000000..ba9a0cd
Binary files /dev/null and b/Windows/samples/GraphNotificationsSample/Assets/smalltile-sdk.png differ
diff --git a/Windows/samples/GraphNotificationsSample/Assets/splash-sdk.png b/Windows/samples/GraphNotificationsSample/Assets/splash-sdk.png
new file mode 100644
index 0000000..e00df02
Binary files /dev/null and b/Windows/samples/GraphNotificationsSample/Assets/splash-sdk.png differ
diff --git a/Windows/samples/GraphNotificationsSample/Assets/squaretile-sdk.png b/Windows/samples/GraphNotificationsSample/Assets/squaretile-sdk.png
new file mode 100644
index 0000000..f97c34a
Binary files /dev/null and b/Windows/samples/GraphNotificationsSample/Assets/squaretile-sdk.png differ
diff --git a/Windows/samples/GraphNotificationsSample/Assets/storelogo-sdk.png b/Windows/samples/GraphNotificationsSample/Assets/storelogo-sdk.png
new file mode 100644
index 0000000..5c397de
Binary files /dev/null and b/Windows/samples/GraphNotificationsSample/Assets/storelogo-sdk.png differ
diff --git a/Windows/samples/GraphNotificationsSample/Assets/tile-sdk.png b/Windows/samples/GraphNotificationsSample/Assets/tile-sdk.png
new file mode 100644
index 0000000..f72683f
Binary files /dev/null and b/Windows/samples/GraphNotificationsSample/Assets/tile-sdk.png differ
diff --git a/Windows/samples/GraphNotificationsSample/Assets/windows-sdk.png b/Windows/samples/GraphNotificationsSample/Assets/windows-sdk.png
new file mode 100644
index 0000000..6726802
Binary files /dev/null and b/Windows/samples/GraphNotificationsSample/Assets/windows-sdk.png differ
diff --git a/Windows/samples/GraphNotificationsSample/GraphNotificationProvider.cs b/Windows/samples/GraphNotificationsSample/GraphNotificationProvider.cs
new file mode 100644
index 0000000..3778f34
--- /dev/null
+++ b/Windows/samples/GraphNotificationsSample/GraphNotificationProvider.cs
@@ -0,0 +1,280 @@
+//*********************************************************
+//
+// Copyright (c) Microsoft. All rights reserved.
+// This code is licensed under the Microsoft Public License.
+// THIS CODE IS PROVIDED *AS IS* WITHOUT WARRANTY OF
+// ANY KIND, EITHER EXPRESS OR IMPLIED, INCLUDING ANY
+// IMPLIED WARRANTIES OF FITNESS FOR A PARTICULAR
+// PURPOSE, MERCHANTABILITY, OR NON-INFRINGEMENT.
+//
+//*********************************************************
+
+using Microsoft.ConnectedDevices.Core;
+using Microsoft.ConnectedDevices.UserData;
+using Microsoft.ConnectedDevices.UserNotifications;
+using System;
+using System.Collections.Generic;
+using System.Runtime.InteropServices.WindowsRuntime;
+using System.Threading.Tasks;
+using Windows.Data.Xml.Dom;
+using Windows.Foundation;
+using Windows.Storage;
+
+namespace SDKTemplate
+{
+ public class GraphNotificationProvider : IConnectedDevicesNotificationProvider
+ {
+ private ConnectedDevicesPlatform m_platform;
+ private UserDataFeed m_feed;
+ private UserNotificationReader m_reader;
+ private UserNotificationChannel m_channel;
+ private List m_newNotifications = new List();
+ private List m_historicalNotifications = new List();
+ private MicrosoftAccountProvider m_accoutProvider;
+ private string m_pushUri;
+
+ static readonly string PushUriKey = "PushUri";
+
+ public event EventHandler CacheUpdated;
+
+ public bool NewNotifications
+ {
+ get
+ {
+ return m_newNotifications.Count > 0;
+ }
+ }
+
+ public IReadOnlyList HistoricalNotifications
+ {
+ get
+ {
+ return m_historicalNotifications.AsReadOnly();
+ }
+ }
+
+ public GraphNotificationProvider(MicrosoftAccountProvider accountProvider, string pushUri)
+ {
+ m_pushUri = pushUri;
+ if (string.IsNullOrEmpty(pushUri) && ApplicationData.Current.LocalSettings.Values.ContainsKey(PushUriKey))
+ {
+ m_pushUri = ApplicationData.Current.LocalSettings.Values[PushUriKey] as string;
+ }
+ m_accoutProvider = accountProvider;
+ accountProvider.SignOutCompleted += (s, e) => Reset();
+ }
+
+ private async Task GetNotificationRegistration()
+ {
+ return m_pushUri;
+ }
+
+ IAsyncOperation IConnectedDevicesNotificationProvider.GetNotificationRegistrationAsync()
+ {
+ Logger.Instance.LogMessage($"Push registration requested by platform");
+ return GetNotificationRegistration().AsAsyncOperation();
+ }
+
+ event TypedEventHandler IConnectedDevicesNotificationProvider.RegistrationUpdated
+ {
+ add { return new EventRegistrationToken(); }
+ remove { }
+ }
+
+ public async void Refresh()
+ {
+ await SetupChannel();
+ if (m_reader != null)
+ {
+ Logger.Instance.LogMessage("Read cached notifications");
+ ReadNotifications(m_reader);
+ }
+
+ Logger.Instance.LogMessage("Request another sync");
+ m_feed?.StartSync();
+ }
+
+ public async void ReceiveNotification(string content)
+ {
+ await SetupChannel();
+ m_platform.ReceiveNotification(content);
+ }
+
+ public async void Activate(string id, bool dismiss)
+ {
+ await SetupChannel();
+ var notification = m_historicalNotifications.Find((n) => { return (n.Id == id); });
+ if (notification != null)
+ {
+ notification.UserActionState = dismiss ? UserNotificationUserActionState.Dismissed : UserNotificationUserActionState.Activated;
+ await notification.SaveAsync();
+ RemoveToastNotification(notification.Id);
+ Logger.Instance.LogMessage($"{notification.Id} is now DISMISSED");
+ }
+ }
+
+ public async void MarkRead(string id)
+ {
+ var notification = m_historicalNotifications.Find((n) => { return (n.Id == id); });
+ if (notification != null)
+ {
+ notification.ReadState = UserNotificationReadState.Read;
+ await notification.SaveAsync();
+ Logger.Instance.LogMessage($"{notification.Id} is now READ");
+ }
+ }
+
+ public async void Delete(string id)
+ {
+ var notification = m_historicalNotifications.Find((n) => { return (n.Id == id); });
+ if (notification != null)
+ {
+ await m_channel?.DeleteUserNotificationAsync(notification.Id);
+ Logger.Instance.LogMessage($"{notification.Id} is now DELETED");
+ }
+ }
+
+ public async void Reset()
+ {
+ if (m_platform != null)
+ {
+ Logger.Instance.LogMessage("Shutting down platform");
+ await m_platform.ShutdownAsync();
+ m_platform = null;
+ m_feed = null;
+ m_newNotifications.Clear();
+ m_historicalNotifications.Clear();
+ }
+
+ CacheUpdated?.Invoke(this, new EventArgs());
+ }
+
+ private async Task SetupChannel()
+ {
+ var account = m_accoutProvider.SignedInAccount;
+ if (account != null && m_platform == null)
+ {
+ m_platform = new ConnectedDevicesPlatform(m_accoutProvider, this);
+ }
+
+ if (m_feed == null)
+ {
+ // Need to run UserDataFeed creation on a background thread
+ // because MSA/AAD token request might need to show UI.
+ await Task.Run(() =>
+ {
+ lock (this)
+ {
+ if (account != null && m_feed == null)
+ {
+ try
+ {
+ m_feed = new UserDataFeed(account, m_platform, "graphnotifications.sample.windows.com");
+ m_feed.SyncStatusChanged += Feed_SyncStatusChanged;
+ m_feed.AddSyncScopes(new List
+ {
+ UserNotificationChannel.SyncScope
+ });
+
+ m_channel = new UserNotificationChannel(m_feed);
+ m_reader = m_channel.CreateReader();
+ m_reader.DataChanged += Reader_DataChanged;
+
+ Logger.Instance.LogMessage($"Setup feed for {account.Id} {account.Type}");
+ }
+ catch (Exception ex)
+ {
+ Logger.Instance.LogMessage($"Failed to setup UserNotificationChannel {ex.Message}");
+ m_feed = null;
+ }
+ }
+ }
+ });
+ }
+ }
+
+ private async void ReadNotifications(UserNotificationReader reader)
+ {
+ var notifications = await reader.ReadBatchAsync(UInt32.MaxValue);
+ Logger.Instance.LogMessage($"Read {notifications.Count} notifications");
+
+ foreach (var notification in notifications)
+ {
+ //Logger.Instance.LogMessage($"UserNotification: {notification.Id} Status: {notification.Status} ReadState: {notification.ReadState} UserActionState: {notification.UserActionState}");
+
+ if (notification.Status == UserNotificationStatus.Active)
+ {
+ m_newNotifications.RemoveAll((n) => { return (n.Id == notification.Id); });
+ if (notification.UserActionState == UserNotificationUserActionState.NoInteraction)
+ {
+ // Brand new notification, add to new
+ m_newNotifications.Add(notification);
+ Logger.Instance.LogMessage($"UserNotification not interacted: {notification.Id}");
+ if (!string.IsNullOrEmpty(notification.Content) && notification.ReadState != UserNotificationReadState.Read)
+ {
+ RemoveToastNotification(notification.Id);
+ ShowToastNotification(BuildToastNotification(notification.Id, notification.Content));
+ }
+ }
+ else
+ {
+ RemoveToastNotification(notification.Id);
+ }
+
+ m_historicalNotifications.RemoveAll((n) => { return (n.Id == notification.Id); });
+ m_historicalNotifications.Insert(0, notification);
+ }
+ else
+ {
+ // Historical notification is marked as deleted, remove from display
+ m_newNotifications.RemoveAll((n) => { return (n.Id == notification.Id); });
+ m_historicalNotifications.RemoveAll((n) => { return (n.Id == notification.Id); });
+ RemoveToastNotification(notification.Id);
+ }
+ }
+
+ CacheUpdated?.Invoke(this, new EventArgs());
+ }
+
+ private void Feed_SyncStatusChanged(UserDataFeed sender, object args)
+ {
+ Logger.Instance.LogMessage($"SyncStatus is {sender.SyncStatus.ToString()}");
+ }
+
+ private void Reader_DataChanged(UserNotificationReader sender, object args)
+ {
+ Logger.Instance.LogMessage("New notification available");
+ ReadNotifications(sender);
+ }
+
+ public static Windows.UI.Notifications.ToastNotification BuildToastNotification(string notificationId, string notificationContent)
+ {
+ XmlDocument toastXml = Windows.UI.Notifications.ToastNotificationManager.GetTemplateContent(Windows.UI.Notifications.ToastTemplateType.ToastText02);
+ XmlNodeList toastNodeList = toastXml.GetElementsByTagName("text");
+ toastNodeList.Item(0).AppendChild(toastXml.CreateTextNode(notificationId));
+ toastNodeList.Item(1).AppendChild(toastXml.CreateTextNode(notificationContent));
+ IXmlNode toastNode = toastXml.SelectSingleNode("/toast");
+ ((XmlElement)toastNode).SetAttribute("launch", "{\"type\":\"toast\",\"notificationId\":\"" + notificationId + "\"}");
+ XmlElement audio = toastXml.CreateElement("audio");
+ audio.SetAttribute("src", "ms-winsoundevent:Notification.SMS");
+ return new Windows.UI.Notifications.ToastNotification(toastXml)
+ {
+ Tag = notificationId
+ };
+ }
+
+ // Raise a new toast with UserNotification.Id as tag
+ private void ShowToastNotification(Windows.UI.Notifications.ToastNotification toast)
+ {
+ var toastNotifier = Windows.UI.Notifications.ToastNotificationManager.CreateToastNotifier();
+ toast.Activated += (s, e) => Activate(s.Tag, false);
+ toastNotifier.Show(toast);
+ }
+
+ // Remove a toast with UserNotification.Id as tag
+ private void RemoveToastNotification(string notificationId)
+ {
+ Windows.UI.Notifications.ToastNotificationManager.History.Remove(notificationId);
+ }
+ }
+}
diff --git a/Windows/samples/GraphNotificationsSample/GraphNotificationsSample.csproj b/Windows/samples/GraphNotificationsSample/GraphNotificationsSample.csproj
new file mode 100644
index 0000000..6955eea
--- /dev/null
+++ b/Windows/samples/GraphNotificationsSample/GraphNotificationsSample.csproj
@@ -0,0 +1,203 @@
+
+
+
+
+ Debug
+ x86
+ {DC30CE66-DAEE-4CCF-BD02-8837FE918B6F}
+ AppContainerExe
+ Properties
+ SDKTemplate
+ GraphNotificationsSample
+ en-US
+ UAP
+ 10.0.17134.0
+ 10.0.15063.0
+ 14
+ 512
+ {A5A43C5B-DE2A-4C0C-9213-0A381AF9435A};{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}
+ win10-arm;win10-arm-aot;win10-x86;win10-x86-aot;win10-x64;win10-x64-aot
+ False
+ False
+ Always
+ x64
+ 3FD265E4FC786A7A28192B070A81132FF51AA0CE
+ GraphNotificationsSample_TemporaryKey.pfx
+ 1
+ OnApplicationRun
+ False
+
+
+ true
+ bin\ARM\Debug\
+ DEBUG;TRACE;NETFX_CORE;WINDOWS_UWP
+ ;2008
+ full
+ ARM
+ false
+ prompt
+ true
+
+
+ bin\ARM\Release\
+ TRACE;NETFX_CORE;WINDOWS_UWP
+ true
+ ;2008
+ pdbonly
+ ARM
+ false
+ prompt
+ true
+ true
+
+
+ true
+ bin\x64\Debug\
+ DEBUG;TRACE;NETFX_CORE;WINDOWS_UWP
+ ;2008
+ full
+ x64
+ false
+ prompt
+ true
+
+
+ bin\x64\Release\
+ TRACE;NETFX_CORE;WINDOWS_UWP
+ true
+ ;2008
+ pdbonly
+ x64
+ false
+ prompt
+ true
+ true
+
+
+ true
+ bin\x86\Debug\
+ DEBUG;TRACE;NETFX_CORE;WINDOWS_UWP
+ ;2008
+ full
+ x86
+ false
+ prompt
+ true
+
+
+ bin\x86\Release\
+ TRACE;NETFX_CORE;WINDOWS_UWP
+ false
+ ;2008
+ pdbonly
+ x86
+ false
+ prompt
+ true
+ true
+
+
+
+
+ LogsPage.xaml
+
+
+
+
+ NotificationsPage.xaml
+
+
+ App.xaml
+
+
+ MainPage.xaml
+
+
+
+ AccountsPage.xaml
+
+
+
+
+
+ Designer
+
+
+
+
+ MSBuild:Compile
+ Designer
+
+
+ MSBuild:Compile
+ Designer
+
+
+ MSBuild:Compile
+ Designer
+
+
+ MSBuild:Compile
+ Designer
+
+
+ MSBuild:Compile
+ Designer
+
+
+ MSBuild:Compile
+ Designer
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ 0.13.6
+
+
+ 3.19.8
+
+
+ 6.2.0-Preview1-26502-02
+
+
+ 11.0.2
+
+
+ 4.3.1
+
+
+ 1.6.0.2
+
+
+
+
+
+
+
+ 14.0
+
+
+ false
+
+
+
+
\ No newline at end of file
diff --git a/Windows/samples/GraphNotificationsSample/GraphNotificationsSample.sln b/Windows/samples/GraphNotificationsSample/GraphNotificationsSample.sln
new file mode 100644
index 0000000..fe63d87
--- /dev/null
+++ b/Windows/samples/GraphNotificationsSample/GraphNotificationsSample.sln
@@ -0,0 +1,40 @@
+
+Microsoft Visual Studio Solution File, Format Version 12.00
+# Visual Studio 14
+VisualStudioVersion = 14.0.22609.0
+MinimumVisualStudioVersion = 10.0.40219.1
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "GraphNotificationsSample", "GraphNotificationsSample.csproj", "{DC30CE66-DAEE-4CCF-BD02-8837FE918B6F}"
+EndProject
+Global
+ GlobalSection(SolutionConfigurationPlatforms) = preSolution
+ Debug|ARM = Debug|ARM
+ Debug|x64 = Debug|x64
+ Debug|x86 = Debug|x86
+ Release|ARM = Release|ARM
+ Release|x64 = Release|x64
+ Release|x86 = Release|x86
+ EndGlobalSection
+ GlobalSection(ProjectConfigurationPlatforms) = postSolution
+ {DC30CE66-DAEE-4CCF-BD02-8837FE918B6F}.Debug|ARM.ActiveCfg = Debug|ARM
+ {DC30CE66-DAEE-4CCF-BD02-8837FE918B6F}.Debug|ARM.Build.0 = Debug|ARM
+ {DC30CE66-DAEE-4CCF-BD02-8837FE918B6F}.Debug|ARM.Deploy.0 = Debug|ARM
+ {DC30CE66-DAEE-4CCF-BD02-8837FE918B6F}.Debug|x64.ActiveCfg = Debug|x64
+ {DC30CE66-DAEE-4CCF-BD02-8837FE918B6F}.Debug|x64.Build.0 = Debug|x64
+ {DC30CE66-DAEE-4CCF-BD02-8837FE918B6F}.Debug|x64.Deploy.0 = Debug|x64
+ {DC30CE66-DAEE-4CCF-BD02-8837FE918B6F}.Debug|x86.ActiveCfg = Debug|x86
+ {DC30CE66-DAEE-4CCF-BD02-8837FE918B6F}.Debug|x86.Build.0 = Debug|x86
+ {DC30CE66-DAEE-4CCF-BD02-8837FE918B6F}.Debug|x86.Deploy.0 = Debug|x86
+ {DC30CE66-DAEE-4CCF-BD02-8837FE918B6F}.Release|ARM.ActiveCfg = Release|ARM
+ {DC30CE66-DAEE-4CCF-BD02-8837FE918B6F}.Release|ARM.Build.0 = Release|ARM
+ {DC30CE66-DAEE-4CCF-BD02-8837FE918B6F}.Release|ARM.Deploy.0 = Release|ARM
+ {DC30CE66-DAEE-4CCF-BD02-8837FE918B6F}.Release|x64.ActiveCfg = Release|x64
+ {DC30CE66-DAEE-4CCF-BD02-8837FE918B6F}.Release|x64.Build.0 = Release|x64
+ {DC30CE66-DAEE-4CCF-BD02-8837FE918B6F}.Release|x64.Deploy.0 = Release|x64
+ {DC30CE66-DAEE-4CCF-BD02-8837FE918B6F}.Release|x86.ActiveCfg = Release|x86
+ {DC30CE66-DAEE-4CCF-BD02-8837FE918B6F}.Release|x86.Build.0 = Release|x86
+ {DC30CE66-DAEE-4CCF-BD02-8837FE918B6F}.Release|x86.Deploy.0 = Release|x86
+ EndGlobalSection
+ GlobalSection(SolutionProperties) = preSolution
+ HideSolutionNode = FALSE
+ EndGlobalSection
+EndGlobal
diff --git a/Windows/samples/GraphNotificationsSample/Logger.cs b/Windows/samples/GraphNotificationsSample/Logger.cs
new file mode 100644
index 0000000..9e69698
--- /dev/null
+++ b/Windows/samples/GraphNotificationsSample/Logger.cs
@@ -0,0 +1,42 @@
+//*********************************************************
+//
+// Copyright (c) Microsoft. All rights reserved.
+// This code is licensed under the Microsoft Public License.
+// THIS CODE IS PROVIDED *AS IS* WITHOUT WARRANTY OF
+// ANY KIND, EITHER EXPRESS OR IMPLIED, INCLUDING ANY
+// IMPLIED WARRANTIES OF FITNESS FOR A PARTICULAR
+// PURPOSE, MERCHANTABILITY, OR NON-INFRINGEMENT.
+//
+//*********************************************************
+
+using System;
+using System.Diagnostics;
+
+namespace SDKTemplate
+{
+ class Logger
+ {
+ public static Logger Instance { get; } = new Logger();
+
+ public delegate void LogEventHandler(object sender, string message);
+ public event LogEventHandler LogUpdated;
+
+ public string AppLogs
+ {
+ get; set;
+ }
+
+ Logger()
+ {
+ AppLogs = string.Empty;
+ }
+
+ public void LogMessage(string message)
+ {
+ message = $"[{string.Format("{0:T}", DateTime.Now)}] {message}";
+ Debug.WriteLine(message);
+ AppLogs = message + Environment.NewLine + AppLogs;
+ LogUpdated?.Invoke(this, message);
+ }
+ }
+}
diff --git a/Windows/samples/GraphNotificationsSample/LogsPage.xaml b/Windows/samples/GraphNotificationsSample/LogsPage.xaml
new file mode 100644
index 0000000..07e29a6
--- /dev/null
+++ b/Windows/samples/GraphNotificationsSample/LogsPage.xaml
@@ -0,0 +1,51 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Please login with MSA or AAD account
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/Windows/samples/GraphNotificationsSample/LogsPage.xaml.cs b/Windows/samples/GraphNotificationsSample/LogsPage.xaml.cs
new file mode 100644
index 0000000..43cabab
--- /dev/null
+++ b/Windows/samples/GraphNotificationsSample/LogsPage.xaml.cs
@@ -0,0 +1,67 @@
+//*********************************************************
+//
+// Copyright (c) Microsoft. All rights reserved.
+// This code is licensed under the Microsoft Public License.
+// THIS CODE IS PROVIDED *AS IS* WITHOUT WARRANTY OF
+// ANY KIND, EITHER EXPRESS OR IMPLIED, INCLUDING ANY
+// IMPLIED WARRANTIES OF FITNESS FOR A PARTICULAR
+// PURPOSE, MERCHANTABILITY, OR NON-INFRINGEMENT.
+//
+//*********************************************************
+
+using System;
+using Windows.UI.Core;
+using Windows.UI.Xaml;
+using Windows.UI.Xaml.Controls;
+using Windows.UI.Xaml.Navigation;
+
+namespace SDKTemplate
+{
+ public sealed partial class LogsPage : Page
+ {
+ private MainPage rootPage;
+
+ public LogsPage()
+ {
+ this.InitializeComponent();
+ }
+
+ protected override void OnNavigatedTo(NavigationEventArgs e)
+ {
+ rootPage = MainPage.Current;
+
+ var accountProvider = ((App)Application.Current).AccountProvider;
+ if (accountProvider.SignedInAccount != null)
+ {
+ Description.Text = $"{accountProvider.SignedInAccount.Type} user ";
+ if (accountProvider.AadUser != null)
+ {
+ Description.Text += accountProvider.AadUser.DisplayableId;
+ }
+ }
+
+ LogView.Text = Logger.Instance.AppLogs;
+ Logger.Instance.LogUpdated += LogsUpdated;
+ }
+
+ protected override void OnNavigatedFrom(NavigationEventArgs e)
+ {
+ Logger.Instance.LogUpdated -= LogsUpdated;
+ }
+
+ private async void LogsUpdated(object sender, string message)
+ {
+ await Dispatcher.RunAsync(CoreDispatcherPriority.Normal, () =>
+ {
+ LogView.Text = message + Environment.NewLine + LogView.Text;
+ });
+ }
+
+ private void Button_Clear(object sender, RoutedEventArgs e)
+ {
+ LogView.Text = string.Empty;
+ Logger.Instance.AppLogs = string.Empty;
+ rootPage.NotifyUser("Logs cleared", NotifyType.ErrorMessage);
+ }
+ }
+}
diff --git a/Windows/samples/GraphNotificationsSample/MainPage.xaml b/Windows/samples/GraphNotificationsSample/MainPage.xaml
new file mode 100644
index 0000000..66667d0
--- /dev/null
+++ b/Windows/samples/GraphNotificationsSample/MainPage.xaml
@@ -0,0 +1,85 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/Windows/samples/GraphNotificationsSample/MainPage.xaml.cs b/Windows/samples/GraphNotificationsSample/MainPage.xaml.cs
new file mode 100644
index 0000000..eadff24
--- /dev/null
+++ b/Windows/samples/GraphNotificationsSample/MainPage.xaml.cs
@@ -0,0 +1,201 @@
+//*********************************************************
+//
+// Copyright (c) Microsoft. All rights reserved.
+// This code is licensed under the MIT License (MIT).
+// THIS CODE IS PROVIDED *AS IS* WITHOUT WARRANTY OF
+// ANY KIND, EITHER EXPRESS OR IMPLIED, INCLUDING ANY
+// IMPLIED WARRANTIES OF FITNESS FOR A PARTICULAR
+// PURPOSE, MERCHANTABILITY, OR NON-INFRINGEMENT.
+//
+//*********************************************************
+
+using System;
+using System.Collections.Generic;
+using Windows.ApplicationModel.Background;
+using Windows.UI.Xaml;
+using Windows.UI.Xaml.Controls;
+using Windows.UI.Xaml.Data;
+using Windows.UI.Xaml.Media;
+using Windows.UI.Xaml.Navigation;
+
+// The Blank Page item template is documented at http://go.microsoft.com/fwlink/?LinkId=402352&clcid=0x409
+
+namespace SDKTemplate
+{
+ ///
+ /// An empty page that can be used on its own or navigated to within a Frame.
+ ///
+ public sealed partial class MainPage : Page
+ {
+ public static MainPage Current;
+ static readonly string PushTaskName = "GraphNotificationsPush";
+
+ public MainPage()
+ {
+ this.InitializeComponent();
+
+ // This is a static public property that allows downstream pages to get a handle to the MainPage instance
+ // in order to call methods that are in this class.
+ Current = this;
+ SampleTitle.Text = FEATURE_NAME;
+ }
+
+ protected override async void OnNavigatedTo(NavigationEventArgs e)
+ {
+ // Applications must have lock screen privileges in order to receive raw notifications
+ BackgroundAccessStatus backgroundStatus = await BackgroundExecutionManager.RequestAccessAsync();
+
+ // Make sure the user allowed privileges
+ if (backgroundStatus != BackgroundAccessStatus.DeniedByUser &&
+ backgroundStatus != BackgroundAccessStatus.DeniedBySystemPolicy &&
+ backgroundStatus != BackgroundAccessStatus.Unspecified)
+ {
+ RegisterBackgroundTask();
+ }
+ else
+ {
+ NotifyUser("Background access is denied", NotifyType.ErrorMessage);
+ }
+
+ // Populate the scenario list from the SampleConfiguration.cs file
+ ScenarioControl.ItemsSource = scenarios;
+ if (Window.Current.Bounds.Width < 640)
+ {
+ ScenarioControl.SelectedIndex = -1;
+ }
+ else
+ {
+ ScenarioControl.SelectedIndex = 0;
+ }
+ }
+
+ private void RegisterBackgroundTask()
+ {
+ // Unregister first
+ UnregisterBackgroundTask();
+
+ try
+ {
+ BackgroundTaskBuilder taskBuilder = new BackgroundTaskBuilder
+ {
+ Name = PushTaskName
+ };
+
+ // Do not set BackgroundTaskBuilder.TaskEntryPoint for in-process background tasks
+ // Here we register the task and work will start based on the time trigger.
+
+ taskBuilder.SetTrigger(new PushNotificationTrigger());
+
+ BackgroundTaskRegistration task = taskBuilder.Register();
+ }
+ catch (Exception ex)
+ {
+ NotifyUser("BackgroundTaskRegistration error: " + ex.Message, NotifyType.ErrorMessage);
+ UnregisterBackgroundTask();
+ }
+ }
+
+ private bool UnregisterBackgroundTask()
+ {
+ foreach (var iter in BackgroundTaskRegistration.AllTasks)
+ {
+ IBackgroundTaskRegistration task = iter.Value;
+ if (task.Name == PushTaskName)
+ {
+ task.Unregister(true);
+ return true;
+ }
+ }
+ return false;
+ }
+
+ ///
+ /// Called whenever the user changes selection in the scenarios list. This method will navigate to the respective
+ /// sample scenario page.
+ ///
+ ///
+ ///
+ private void ScenarioControl_SelectionChanged(object sender, SelectionChangedEventArgs e)
+ {
+ // Clear the status block when navigating scenarios.
+ NotifyUser(String.Empty, NotifyType.StatusMessage);
+
+ ListBox scenarioListBox = sender as ListBox;
+ Scenario s = scenarioListBox.SelectedItem as Scenario;
+ if (s != null)
+ {
+ ScenarioFrame.Navigate(s.ClassType);
+ if (Window.Current.Bounds.Width < 640)
+ {
+ Splitter.IsPaneOpen = false;
+ }
+ }
+ }
+
+ public List Scenarios
+ {
+ get { return this.scenarios; }
+ }
+
+ ///
+ /// Used to display messages to the user
+ ///
+ ///
+ ///
+ public void NotifyUser(string strMessage, NotifyType type)
+ {
+ switch (type)
+ {
+ case NotifyType.StatusMessage:
+ StatusBorder.Background = new SolidColorBrush(Windows.UI.Colors.Green);
+ break;
+ case NotifyType.ErrorMessage:
+ StatusBorder.Background = new SolidColorBrush(Windows.UI.Colors.Red);
+ break;
+ }
+ StatusBlock.Text = strMessage;
+
+ // Collapse the StatusBlock if it has no text to conserve real estate.
+ StatusBorder.Visibility = (StatusBlock.Text != String.Empty) ? Visibility.Visible : Visibility.Collapsed;
+ if (StatusBlock.Text != String.Empty)
+ {
+ StatusBorder.Visibility = Visibility.Visible;
+ StatusPanel.Visibility = Visibility.Visible;
+ }
+ else
+ {
+ StatusBorder.Visibility = Visibility.Collapsed;
+ StatusPanel.Visibility = Visibility.Collapsed;
+ }
+ }
+
+ async void Footer_Click(object sender, RoutedEventArgs e)
+ {
+ await Windows.System.Launcher.LaunchUriAsync(new Uri(((HyperlinkButton)sender).Tag.ToString()));
+ }
+
+ private void Button_Click(object sender, RoutedEventArgs e)
+ {
+ Splitter.IsPaneOpen = !Splitter.IsPaneOpen;
+ }
+ }
+ public enum NotifyType
+ {
+ StatusMessage,
+ ErrorMessage
+ };
+
+ public class ScenarioBindingConverter : IValueConverter
+ {
+ public object Convert(object value, Type targetType, object parameter, string language)
+ {
+ Scenario s = value as Scenario;
+ return (MainPage.Current.Scenarios.IndexOf(s) + 1) + ") " + s.Title;
+ }
+
+ public object ConvertBack(object value, Type targetType, object parameter, string language)
+ {
+ return true;
+ }
+ }
+}
diff --git a/Windows/samples/GraphNotificationsSample/MicrosoftAccountProvider.cs b/Windows/samples/GraphNotificationsSample/MicrosoftAccountProvider.cs
new file mode 100644
index 0000000..957c2c1
--- /dev/null
+++ b/Windows/samples/GraphNotificationsSample/MicrosoftAccountProvider.cs
@@ -0,0 +1,279 @@
+//*********************************************************
+//
+// Copyright (c) Microsoft. All rights reserved.
+// This code is licensed under the Microsoft Public License.
+// THIS CODE IS PROVIDED *AS IS* WITHOUT WARRANTY OF
+// ANY KIND, EITHER EXPRESS OR IMPLIED, INCLUDING ANY
+// IMPLIED WARRANTIES OF FITNESS FOR A PARTICULAR
+// PURPOSE, MERCHANTABILITY, OR NON-INFRINGEMENT.
+//
+//*********************************************************
+
+using Microsoft.ConnectedDevices.Core;
+using Microsoft.IdentityModel.Clients.ActiveDirectory;
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using System.Net.Http;
+using System.Runtime.InteropServices.WindowsRuntime;
+using System.Security.Cryptography;
+using System.Text;
+using System.Threading.Tasks;
+using Windows.Foundation;
+using Windows.Security.Authentication.Web;
+using Windows.Storage;
+using Xamarin.Auth;
+
+namespace SDKTemplate
+{
+ public class MicrosoftAccountProvider : IConnectedDevicesUserAccountProvider
+ {
+ static readonly string CCSResource = "https://cdpcs.access.microsoft.com";
+
+ static readonly string MsaTokenKey = "MsaToken";
+
+ public event EventHandler SignOutCompleted;
+ public ConnectedDevicesUserAccount SignedInAccount { get; set; }
+ public string MsaToken { get; set; }
+ public UserInfo AadUser { get; set; }
+
+ public MicrosoftAccountProvider()
+ {
+ if (ApplicationData.Current.LocalSettings.Values.ContainsKey(MsaTokenKey))
+ {
+ MsaToken = ApplicationData.Current.LocalSettings.Values[MsaTokenKey] as string;
+ SignedInAccount = new ConnectedDevicesUserAccount(Guid.NewGuid().ToString(), ConnectedDevicesUserAccountType.MSA);
+ }
+ else
+ {
+ var authContext = new AuthenticationContext("https://login.microsoftonline.com/common");
+ if (authContext.TokenCache.Count > 0)
+ {
+ SignedInAccount = new ConnectedDevicesUserAccount(Guid.NewGuid().ToString(), ConnectedDevicesUserAccountType.AAD);
+ }
+ }
+ }
+
+ public async Task SignInAad()
+ {
+ var result = await GetAadTokenForUserAsync(CCSResource);
+ if (result.TokenRequestStatus == ConnectedDevicesAccessTokenRequestStatus.Success)
+ {
+ SignedInAccount = new ConnectedDevicesUserAccount(Guid.NewGuid().ToString(), ConnectedDevicesUserAccountType.AAD);
+ return true;
+ }
+ return false;
+ }
+
+ public async Task SignInMsa()
+ {
+ string refreshToken = string.Empty;
+ if (ApplicationData.Current.LocalSettings.Values.ContainsKey(MsaTokenKey))
+ {
+ refreshToken = ApplicationData.Current.LocalSettings.Values[MsaTokenKey] as string;
+ }
+
+ if (string.IsNullOrEmpty(refreshToken))
+ {
+ refreshToken = await MSAOAuthHelpers.GetRefreshTokenAsync();
+ }
+
+ if (!string.IsNullOrEmpty(refreshToken))
+ {
+ MsaToken = refreshToken;
+ ApplicationData.Current.LocalSettings.Values[MsaTokenKey] = refreshToken;
+ SignedInAccount = new ConnectedDevicesUserAccount(Guid.NewGuid().ToString(), ConnectedDevicesUserAccountType.MSA);
+ return true;
+ }
+
+ return false;
+ }
+
+ public void SignOut()
+ {
+ MsaToken = string.Empty;
+ AadUser = null;
+ SignedInAccount = null;
+ ApplicationData.Current.LocalSettings.Values.Remove(MsaTokenKey);
+ new AuthenticationContext("https://login.microsoftonline.com/common").TokenCache.Clear();
+ SignOutCompleted?.Invoke(null, new EventArgs());
+ }
+
+ IAsyncOperation IConnectedDevicesUserAccountProvider.GetAccessTokenForUserAccountAsync(string userAccountId, IReadOnlyList scopes)
+ {
+ Logger.Instance.LogMessage($"Token requested by platform for {userAccountId} and {string.Join(" ", scopes)}");
+ if (SignedInAccount.Type == ConnectedDevicesUserAccountType.AAD)
+ {
+ return GetAadTokenForUserAsync(scopes.First()).AsAsyncOperation();
+ }
+ else
+ {
+ return GetMsaTokenForUserAsync(scopes).AsAsyncOperation();
+ }
+ }
+
+ void IConnectedDevicesUserAccountProvider.OnAccessTokenError(string userAccountId, IReadOnlyList scopes, bool isPermanentError)
+ {
+ Logger.Instance.LogMessage($"Bad token reported for {userAccountId} isPermanentError: {isPermanentError}");
+ }
+
+ IReadOnlyList IConnectedDevicesUserAccountProvider.UserAccounts
+ {
+ get
+ {
+ var accounts = new List();
+ var account = SignedInAccount;
+ if (account != null)
+ {
+ accounts.Add(account);
+ }
+ return accounts;
+ }
+ }
+
+ event TypedEventHandler IConnectedDevicesUserAccountProvider.UserAccountChanged
+ {
+ add { return new EventRegistrationToken(); }
+ remove { }
+ }
+
+ private async Task GetAadTokenForUserAsync(string audience)
+ {
+ try
+ {
+ var authContext = new AuthenticationContext("https://login.microsoftonline.com/common");
+ AuthenticationResult result = await authContext.AcquireTokenAsync(
+ audience, Secrets.AAD_CLIENT_ID, new Uri(Secrets.AAD_REDIRECT_URI), new PlatformParameters(PromptBehavior.Auto, true));
+ if (AadUser == null)
+ {
+ AadUser = result.UserInfo;
+ Logger.Instance.LogMessage($"SignIn done for {AadUser.DisplayableId}");
+ }
+ Logger.Instance.LogMessage($"AAD Token : {result.AccessToken}");
+ return new ConnectedDevicesAccessTokenResult(result.AccessToken, ConnectedDevicesAccessTokenRequestStatus.Success);
+ }
+ catch (Exception ex)
+ {
+ Logger.Instance.LogMessage($"AAD Token request failed: {ex.Message}");
+ return new ConnectedDevicesAccessTokenResult(string.Empty, ConnectedDevicesAccessTokenRequestStatus.TransientError);
+ }
+ }
+
+ private async Task GetMsaTokenForUserAsync(IReadOnlyList scopes)
+ {
+ try
+ {
+ string accessToken = await MSAOAuthHelpers.GetAccessTokenUsingRefreshTokenAsync(MsaToken, scopes);
+ Logger.Instance.LogMessage($"MSA Token : {accessToken}");
+ return new ConnectedDevicesAccessTokenResult(accessToken, ConnectedDevicesAccessTokenRequestStatus.Success);
+ }
+ catch (Exception ex)
+ {
+ Logger.Instance.LogMessage($"MSA Token request failed: {ex.Message}");
+ return new ConnectedDevicesAccessTokenResult(string.Empty, ConnectedDevicesAccessTokenRequestStatus.TransientError);
+ }
+ }
+ }
+
+ public class MSAOAuthHelpers
+ {
+ static readonly string ProdAuthorizeUrl = "https://login.live.com/oauth20_authorize.srf";
+ static readonly string ProdRedirectUrl = "https://login.microsoftonline.com/common/oauth2/nativeclient";
+ static readonly string ProdAccessTokenUrl = "https://login.live.com/oauth20_token.srf";
+
+ static readonly string OfflineAccessScope = "wl.offline_access";
+ static readonly string CCSScope = "ccs.ReadWrite";
+ static readonly string UserActivitiesScope = "https://activity.windows.com/UserActivity.ReadWrite.CreatedByApp";
+ static readonly string UserNotificationsScope = "https://activity.windows.com/Notifications.ReadWrite.CreatedByApp";
+
+ static Random Randomizer = new Random((int)DateTime.Now.Ticks);
+ static SHA256 HashProvider = SHA256.Create();
+
+ static async Task> RequestAccessTokenAsync(string accessTokenUrl, IDictionary queryValues)
+ {
+ var content = new FormUrlEncodedContent(queryValues);
+
+ HttpClient client = new HttpClient();
+ HttpResponseMessage response = await client.PostAsync(accessTokenUrl, content).ConfigureAwait(false);
+ string text = await response.Content.ReadAsStringAsync().ConfigureAwait(false);
+
+ // Parse the response
+ IDictionary data = text.Contains("{") ? WebEx.JsonDecode(text) : WebEx.FormDecode(text);
+ if (data.ContainsKey("error"))
+ {
+ throw new AuthException(data["error_description"]);
+ }
+
+ return data;
+ }
+
+ public static async Task GetRefreshTokenAsync()
+ {
+ byte[] buffer = new byte[32];
+ Randomizer.NextBytes(buffer);
+ var codeVerifier = Convert.ToBase64String(buffer).Replace('+', '-').Replace('/', '_').Replace("=", "");
+
+ byte[] hash = HashProvider.ComputeHash(Encoding.UTF8.GetBytes(codeVerifier));
+ var codeChallenge = Convert.ToBase64String(hash).Replace('+', '-').Replace('/', '_').Replace("=", "");
+
+ var redirectUri = new Uri(ProdRedirectUrl);
+
+ string scope = $"{OfflineAccessScope} {CCSScope} {UserNotificationsScope} {UserActivitiesScope}";
+ var startUri = new Uri($"{ProdAuthorizeUrl}?client_id={Secrets.MSA_CLIENT_ID}&response_type=code&code_challenge_method=S256&code_challenge={codeChallenge}&redirect_uri={ProdRedirectUrl}&scope={scope}");
+
+ var webAuthenticationResult = await WebAuthenticationBroker.AuthenticateAsync(
+ WebAuthenticationOptions.None,
+ startUri,
+ redirectUri);
+
+ if (webAuthenticationResult.ResponseStatus == WebAuthenticationStatus.Success)
+ {
+ var codeResponseUri = new Uri(webAuthenticationResult.ResponseData);
+ IDictionary queryParams = WebEx.FormDecode(codeResponseUri.Query);
+ if (!queryParams.ContainsKey("code"))
+ {
+ return string.Empty;
+ }
+
+ string authCode = queryParams["code"];
+ Dictionary refreshTokenQuery = new Dictionary
+ {
+ { "client_id", ProdClientId },
+ { "redirect_uri", redirectUri.AbsoluteUri },
+ { "grant_type", "authorization_code" },
+ { "code", authCode },
+ { "code_verifier", codeVerifier },
+ { "scope", CCSScope }
+ };
+
+ IDictionary refreshTokenResponse = await RequestAccessTokenAsync(ProdAccessTokenUrl, refreshTokenQuery);
+ if (refreshTokenResponse.ContainsKey("refresh_token"))
+ {
+ return refreshTokenResponse["refresh_token"];
+ }
+ }
+
+ return string.Empty;
+ }
+
+ public static async Task GetAccessTokenUsingRefreshTokenAsync(string refreshToken, IReadOnlyList scopes)
+ {
+ Dictionary accessTokenQuery = new Dictionary
+ {
+ { "client_id", ProdClientId },
+ { "redirect_uri", ProdRedirectUrl },
+ { "grant_type", "refresh_token" },
+ { "refresh_token", refreshToken },
+ { "scope", string.Join(" ", scopes.ToArray()) },
+ };
+
+ IDictionary accessTokenResponse = await RequestAccessTokenAsync(ProdAccessTokenUrl, accessTokenQuery);
+ if (accessTokenResponse == null || !accessTokenResponse.ContainsKey("access_token"))
+ {
+ throw new Exception("Unable to fetch access_token!");
+ }
+
+ return accessTokenResponse["access_token"];
+ }
+ }
+}
diff --git a/Windows/samples/GraphNotificationsSample/NotificationsPage.xaml b/Windows/samples/GraphNotificationsSample/NotificationsPage.xaml
new file mode 100644
index 0000000..7221c77
--- /dev/null
+++ b/Windows/samples/GraphNotificationsSample/NotificationsPage.xaml
@@ -0,0 +1,111 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Please login with MSA or AAD account
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/Windows/samples/GraphNotificationsSample/NotificationsPage.xaml.cs b/Windows/samples/GraphNotificationsSample/NotificationsPage.xaml.cs
new file mode 100644
index 0000000..3a1cb37
--- /dev/null
+++ b/Windows/samples/GraphNotificationsSample/NotificationsPage.xaml.cs
@@ -0,0 +1,137 @@
+//*********************************************************
+//
+// Copyright (c) Microsoft. All rights reserved.
+// This code is licensed under the Microsoft Public License.
+// THIS CODE IS PROVIDED *AS IS* WITHOUT WARRANTY OF
+// ANY KIND, EITHER EXPRESS OR IMPLIED, INCLUDING ANY
+// IMPLIED WARRANTIES OF FITNESS FOR A PARTICULAR
+// PURPOSE, MERCHANTABILITY, OR NON-INFRINGEMENT.
+//
+//*********************************************************
+
+using Microsoft.ConnectedDevices.UserNotifications;
+using System;
+using System.Collections.ObjectModel;
+using Windows.UI;
+using Windows.UI.Core;
+using Windows.UI.Xaml;
+using Windows.UI.Xaml.Controls;
+using Windows.UI.Xaml.Data;
+using Windows.UI.Xaml.Media;
+using Windows.UI.Xaml.Navigation;
+
+namespace SDKTemplate
+{
+ class NotificationListItem
+ {
+ public string Id { get; set; }
+ public string Content { get; set; }
+ public bool UnreadState { get; set; }
+ public string UserActionState { get; set; }
+ public string Priority { get; set; }
+ public string ExpirationTime { get; set; }
+ public string ChangeTime { get; set; }
+ }
+
+ public class BoolColorConverter : IValueConverter
+ {
+ object IValueConverter.Convert(object value, Type targetType, object parameter, string language)
+ {
+ return new SolidColorBrush(((bool)value) ? Colors.Green : Colors.Red);
+ }
+
+ object IValueConverter.ConvertBack(object value, Type targetType, object parameter, string language)
+ {
+ throw new NotImplementedException();
+ }
+ }
+
+ public partial class NotificationsPage : Page
+ {
+ private MainPage rootPage;
+ private ObservableCollection activeNotifications = new ObservableCollection();
+ private GraphNotificationProvider notificationCache;
+
+ public NotificationsPage()
+ {
+ InitializeComponent();
+
+ UnreadView.ItemsSource = activeNotifications;
+ }
+
+ protected override void OnNavigatedTo(NavigationEventArgs e)
+ {
+ rootPage = MainPage.Current;
+ var accountProvider = ((App)Application.Current).AccountProvider;
+ RefreshButton.IsEnabled = (accountProvider.SignedInAccount != null);
+ if (accountProvider.SignedInAccount != null)
+ {
+ Description.Text = $"{accountProvider.SignedInAccount.Type} user ";
+ if (accountProvider.AadUser != null)
+ {
+ Description.Text += accountProvider.AadUser.DisplayableId;
+ }
+
+ notificationCache = ((App)Application.Current).NotificationProvider;
+ notificationCache.CacheUpdated += Cache_CacheUpdated;
+ notificationCache.Refresh();
+ }
+ }
+
+ protected override void OnNavigatedFrom(NavigationEventArgs e)
+ {
+ if (notificationCache != null)
+ {
+ notificationCache.CacheUpdated -= Cache_CacheUpdated;
+ }
+ }
+
+ private async void Cache_CacheUpdated(object sender, EventArgs e)
+ {
+ await Dispatcher.RunAsync(CoreDispatcherPriority.Normal, () =>
+ {
+ activeNotifications.Clear();
+ foreach (UserNotification notification in notificationCache.HistoricalNotifications)
+ {
+ activeNotifications.Add(new NotificationListItem()
+ {
+ Id = notification.Id,
+ Content = $" Content:{notification.Content}",
+ UnreadState = notification.ReadState == UserNotificationReadState.Unread,
+ UserActionState = notification.UserActionState.ToString(),
+ Priority = $" Priority: {notification.Priority.ToString()}",
+ ExpirationTime = $" Expiry: {notification.ExpirationTime.ToLocalTime().ToString()}",
+ ChangeTime = $" Last Updated: {notification.ChangeTime.ToLocalTime().ToString()}",
+ });
+ }
+
+ if (notificationCache.NewNotifications)
+ {
+ rootPage.NotifyUser("History is up-to-date. New notifications available", NotifyType.StatusMessage);
+ }
+ else
+ {
+ rootPage.NotifyUser("History is up-to-date", NotifyType.StatusMessage);
+ }
+ });
+ }
+
+ private void Button_Refresh(object sender, RoutedEventArgs e)
+ {
+ rootPage.NotifyUser("Updating history", NotifyType.StatusMessage);
+ notificationCache.Refresh();
+ }
+
+ private void Button_MarkRead(object sender, RoutedEventArgs e)
+ {
+ var item = ((Grid)((Border)((Button)sender).Parent).Parent).DataContext as NotificationListItem;
+ notificationCache.MarkRead(item.Id);
+ }
+
+ private void Button_Delete(object sender, RoutedEventArgs e)
+ {
+ var item = ((Grid)((Border)((Button)sender).Parent).Parent).DataContext as NotificationListItem;
+ notificationCache.Delete(item.Id);
+ }
+ }
+}
\ No newline at end of file
diff --git a/Windows/samples/GraphNotificationsSample/Package.appxmanifest b/Windows/samples/GraphNotificationsSample/Package.appxmanifest
new file mode 100644
index 0000000..8c71d16
--- /dev/null
+++ b/Windows/samples/GraphNotificationsSample/Package.appxmanifest
@@ -0,0 +1,32 @@
+
+
+
+
+
+ Graph Notifications Sample
+ Graph Notifications
+ Assets\StoreLogo-sdk.png
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/Windows/samples/GraphNotificationsSample/Properties/AssemblyInfo.cs b/Windows/samples/GraphNotificationsSample/Properties/AssemblyInfo.cs
new file mode 100644
index 0000000..90294f0
--- /dev/null
+++ b/Windows/samples/GraphNotificationsSample/Properties/AssemblyInfo.cs
@@ -0,0 +1,29 @@
+using System.Reflection;
+using System.Runtime.CompilerServices;
+using System.Runtime.InteropServices;
+
+// General Information about an assembly is controlled through the following
+// set of attributes. Change these attribute values to modify the information
+// associated with an assembly.
+[assembly: AssemblyTitle("GraphNotificationsSample")]
+[assembly: AssemblyDescription("GraphNotifications C# Sample")]
+[assembly: AssemblyConfiguration("")]
+[assembly: AssemblyCompany("Microsoft Corporation")]
+[assembly: AssemblyProduct("Windows Samples")]
+[assembly: AssemblyCopyright("Copyright © Microsoft Corporation")]
+[assembly: AssemblyTrademark("")]
+[assembly: AssemblyCulture("")]
+
+// Version information for an assembly consists of the following four values:
+//
+// Major Version
+// Minor Version
+// Build Number
+// Revision
+//
+// You can specify all the values or you can default the Build and Revision Numbers
+// by using the '*' as shown below:
+// [assembly: AssemblyVersion("1.0.*")]
+[assembly: AssemblyVersion("1.0.0.0")]
+[assembly: AssemblyFileVersion("1.0.0.0")]
+[assembly: ComVisible(false)]
diff --git a/Windows/samples/GraphNotificationsSample/Properties/Default.rd.xml b/Windows/samples/GraphNotificationsSample/Properties/Default.rd.xml
new file mode 100644
index 0000000..80a960c
--- /dev/null
+++ b/Windows/samples/GraphNotificationsSample/Properties/Default.rd.xml
@@ -0,0 +1,31 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/Windows/samples/GraphNotificationsSample/SampleConfiguration.cs b/Windows/samples/GraphNotificationsSample/SampleConfiguration.cs
new file mode 100644
index 0000000..f555bf4
--- /dev/null
+++ b/Windows/samples/GraphNotificationsSample/SampleConfiguration.cs
@@ -0,0 +1,41 @@
+//*********************************************************
+//
+// Copyright (c) Microsoft. All rights reserved.
+// This code is licensed under the MIT License (MIT).
+// THIS CODE IS PROVIDED *AS IS* WITHOUT WARRANTY OF
+// ANY KIND, EITHER EXPRESS OR IMPLIED, INCLUDING ANY
+// IMPLIED WARRANTIES OF FITNESS FOR A PARTICULAR
+// PURPOSE, MERCHANTABILITY, OR NON-INFRINGEMENT.
+//
+//*********************************************************
+
+using System;
+using System.Collections.Generic;
+using Windows.UI.Xaml.Controls;
+
+// Important Note for running this sample:
+// The sample as-is will not be able to get tokens without having it's app manifest being
+// modified to use the App Identity of a registered Microsoft Store/registered AAD app.
+//
+// See 'Related Topics' in the README.md for instructions on how to register an app.
+
+namespace SDKTemplate
+{
+ public partial class MainPage : Page
+ {
+ public const string FEATURE_NAME = "Graph Notifications";
+
+ List scenarios = new List
+ {
+ new Scenario() { Title="Login/Logout", ClassType=typeof(AccountsPage)},
+ new Scenario() { Title="Notification History", ClassType=typeof(NotificationsPage)},
+ new Scenario() { Title="App Logs", ClassType=typeof(LogsPage)},
+ };
+ }
+
+ public class Scenario
+ {
+ public string Title { get; set; }
+ public Type ClassType { get; set; }
+ }
+}
diff --git a/Windows/samples/GraphNotificationsSample/Secrets.cs.example b/Windows/samples/GraphNotificationsSample/Secrets.cs.example
new file mode 100644
index 0000000..3b3cf42
--- /dev/null
+++ b/Windows/samples/GraphNotificationsSample/Secrets.cs.example
@@ -0,0 +1,17 @@
+namespace SDKTemplate
+{
+ 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 readonly string MSA_CLIENT_ID = "<>";
+ static readonly string AAD_CLIENT_ID = "<>";
+ static readonly string AAD_REDIRECT_URI = "<>";
+ static readonly string APP_HOST_NAME = "<>";
+ }
+}
diff --git a/Windows/samples/GraphNotificationsSample/Styles/Styles.xaml b/Windows/samples/GraphNotificationsSample/Styles/Styles.xaml
new file mode 100644
index 0000000..50602ae
--- /dev/null
+++ b/Windows/samples/GraphNotificationsSample/Styles/Styles.xaml
@@ -0,0 +1,536 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/iOS/samples/GraphNotificationsSample.xcodeproj/project.pbxproj b/iOS/samples/GraphNotificationsSample.xcodeproj/project.pbxproj
new file mode 100644
index 0000000..8d9942c
--- /dev/null
+++ b/iOS/samples/GraphNotificationsSample.xcodeproj/project.pbxproj
@@ -0,0 +1,509 @@
+// !$*UTF8*$!
+{
+ archiveVersion = 1;
+ classes = {
+ };
+ objectVersion = 48;
+ objects = {
+
+/* Begin PBXBuildFile section */
+ 0017A4902135B52800EB86D8 /* AADAccountProvider.m in Sources */ = {isa = PBXBuildFile; fileRef = 0017A4892135B52700EB86D8 /* AADAccountProvider.m */; };
+ 0017A4912135B52800EB86D8 /* MSATokenRequest.m in Sources */ = {isa = PBXBuildFile; fileRef = 0017A48A2135B52700EB86D8 /* MSATokenRequest.m */; };
+ 0017A4922135B52800EB86D8 /* AADMSAAccountProvider.m in Sources */ = {isa = PBXBuildFile; fileRef = 0017A48C2135B52700EB86D8 /* AADMSAAccountProvider.m */; };
+ 0017A4932135B52800EB86D8 /* MSATokenCache.m in Sources */ = {isa = PBXBuildFile; fileRef = 0017A48D2135B52700EB86D8 /* MSATokenCache.m */; };
+ 0017A4942135B52800EB86D8 /* MSAAccountProvider.m in Sources */ = {isa = PBXBuildFile; fileRef = 0017A48F2135B52800EB86D8 /* MSAAccountProvider.m */; };
+ 0017A49D2135D10E00EB86D8 /* NotificationProvider.m in Sources */ = {isa = PBXBuildFile; fileRef = 0017A49C2135D10E00EB86D8 /* NotificationProvider.m */; };
+ 003421A921347622007FC970 /* NotificationsManager.m in Sources */ = {isa = PBXBuildFile; fileRef = 003421A821347622007FC970 /* NotificationsManager.m */; };
+ 00823361212F114B0055F6E4 /* AppDelegate.m in Sources */ = {isa = PBXBuildFile; fileRef = 00823360212F114B0055F6E4 /* AppDelegate.m */; };
+ 00823364212F114B0055F6E4 /* RootViewController.m in Sources */ = {isa = PBXBuildFile; fileRef = 00823363212F114B0055F6E4 /* RootViewController.m */; };
+ 00823367212F114B0055F6E4 /* LoginViewController.m in Sources */ = {isa = PBXBuildFile; fileRef = 00823366212F114B0055F6E4 /* LoginViewController.m */; };
+ 0082336A212F114B0055F6E4 /* ModelController.m in Sources */ = {isa = PBXBuildFile; fileRef = 00823369212F114B0055F6E4 /* ModelController.m */; };
+ 0082336D212F114B0055F6E4 /* Main.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 0082336B212F114B0055F6E4 /* Main.storyboard */; };
+ 0082336F212F114B0055F6E4 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 0082336E212F114B0055F6E4 /* Assets.xcassets */; };
+ 00823372212F114B0055F6E4 /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 00823370212F114B0055F6E4 /* LaunchScreen.storyboard */; };
+ 00823375212F114B0055F6E4 /* main.m in Sources */ = {isa = PBXBuildFile; fileRef = 00823374212F114B0055F6E4 /* main.m */; };
+ 00D6D060213720E5008E5E33 /* NotificationsViewController.m in Sources */ = {isa = PBXBuildFile; fileRef = 00D6D05F213720E5008E5E33 /* NotificationsViewController.m */; };
+ 00D6D06321384D00008E5E33 /* HistoryViewController.m in Sources */ = {isa = PBXBuildFile; fileRef = 00D6D06221384D00008E5E33 /* HistoryViewController.m */; };
+ 5D93128427C67BC7232939D5 /* libPods-GraphNotifications.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 9B6312F300B55978A0074729 /* libPods-GraphNotifications.a */; };
+/* End PBXBuildFile section */
+
+/* Begin PBXFileReference section */
+ 0017A4892135B52700EB86D8 /* AADAccountProvider.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = AADAccountProvider.m; sourceTree = ""; };
+ 0017A48A2135B52700EB86D8 /* MSATokenRequest.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = MSATokenRequest.m; sourceTree = ""; };
+ 0017A48B2135B52700EB86D8 /* MSATokenCache.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = MSATokenCache.h; sourceTree = ""; };
+ 0017A48C2135B52700EB86D8 /* AADMSAAccountProvider.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = AADMSAAccountProvider.m; sourceTree = ""; };
+ 0017A48D2135B52700EB86D8 /* MSATokenCache.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = MSATokenCache.m; sourceTree = ""; };
+ 0017A48E2135B52700EB86D8 /* MSATokenRequest.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = MSATokenRequest.h; sourceTree = ""; };
+ 0017A48F2135B52800EB86D8 /* MSAAccountProvider.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = MSAAccountProvider.m; sourceTree = ""; };
+ 0017A4952135B53C00EB86D8 /* SingleUserAccountProvider.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = SingleUserAccountProvider.h; sourceTree = ""; };
+ 0017A4962135B53C00EB86D8 /* MSAAccountProvider.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = MSAAccountProvider.h; sourceTree = ""; };
+ 0017A4972135B53C00EB86D8 /* SampleAccountActionFailureReason.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = SampleAccountActionFailureReason.h; sourceTree = ""; };
+ 0017A4982135B53C00EB86D8 /* AADAccountProvider.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = AADAccountProvider.h; sourceTree = ""; };
+ 0017A4992135B53C00EB86D8 /* AADMSAAccountProvider.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = AADMSAAccountProvider.h; sourceTree = ""; };
+ 0017A49B2135D10A00EB86D8 /* NotificationProvider.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = NotificationProvider.h; sourceTree = ""; };
+ 0017A49C2135D10E00EB86D8 /* NotificationProvider.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = NotificationProvider.m; sourceTree = ""; };
+ 003421A72130A887007FC970 /* NotificationsManager.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = NotificationsManager.h; sourceTree = ""; };
+ 003421A821347622007FC970 /* NotificationsManager.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = NotificationsManager.m; sourceTree = ""; };
+ 0082335C212F114B0055F6E4 /* GraphNotifications.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = GraphNotifications.app; sourceTree = BUILT_PRODUCTS_DIR; };
+ 0082335F212F114B0055F6E4 /* AppDelegate.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = AppDelegate.h; sourceTree = ""; };
+ 00823360212F114B0055F6E4 /* AppDelegate.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = AppDelegate.m; sourceTree = ""; };
+ 00823362212F114B0055F6E4 /* RootViewController.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = RootViewController.h; sourceTree = ""; };
+ 00823363212F114B0055F6E4 /* RootViewController.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = RootViewController.m; sourceTree = ""; };
+ 00823365212F114B0055F6E4 /* LoginViewController.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = LoginViewController.h; sourceTree = ""; };
+ 00823366212F114B0055F6E4 /* LoginViewController.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = LoginViewController.m; sourceTree = ""; };
+ 00823368212F114B0055F6E4 /* ModelController.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = ModelController.h; sourceTree = ""; };
+ 00823369212F114B0055F6E4 /* ModelController.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = ModelController.m; sourceTree = ""; };
+ 0082336C212F114B0055F6E4 /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/Main.storyboard; sourceTree = ""; };
+ 0082336E212F114B0055F6E4 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; };
+ 00823371212F114B0055F6E4 /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/LaunchScreen.storyboard; sourceTree = ""; };
+ 00823373212F114B0055F6E4 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; };
+ 00823374212F114B0055F6E4 /* main.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = main.m; sourceTree = ""; };
+ 00D6D05E21372041008E5E33 /* NotificationsViewController.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = NotificationsViewController.h; sourceTree = ""; };
+ 00D6D05F213720E5008E5E33 /* NotificationsViewController.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = NotificationsViewController.m; sourceTree = ""; };
+ 00D6D06121384BA3008E5E33 /* HistoryViewController.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = HistoryViewController.h; sourceTree = ""; };
+ 00D6D06221384D00008E5E33 /* HistoryViewController.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = HistoryViewController.m; sourceTree = ""; };
+ 00D6D0642138519C008E5E33 /* GraphNotificationsSample.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = GraphNotificationsSample.entitlements; sourceTree = ""; };
+ 63E3FBAFA254E6B80A8DA76E /* Pods-GraphNotifications.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-GraphNotifications.release.xcconfig"; path = "Pods/Target Support Files/Pods-GraphNotifications/Pods-GraphNotifications.release.xcconfig"; sourceTree = ""; };
+ 9B6312F300B55978A0074729 /* libPods-GraphNotifications.a */ = {isa = PBXFileReference; explicitFileType = archive.ar; includeInIndex = 0; path = "libPods-GraphNotifications.a"; sourceTree = BUILT_PRODUCTS_DIR; };
+ FFB1B176FF8985E5AD4B88D1 /* Pods-GraphNotifications.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-GraphNotifications.debug.xcconfig"; path = "Pods/Target Support Files/Pods-GraphNotifications/Pods-GraphNotifications.debug.xcconfig"; sourceTree = ""; };
+/* End PBXFileReference section */
+
+/* Begin PBXFrameworksBuildPhase section */
+ 00823359212F114B0055F6E4 /* Frameworks */ = {
+ isa = PBXFrameworksBuildPhase;
+ buildActionMask = 2147483647;
+ files = (
+ 5D93128427C67BC7232939D5 /* libPods-GraphNotifications.a in Frameworks */,
+ );
+ runOnlyForDeploymentPostprocessing = 0;
+ };
+/* End PBXFrameworksBuildPhase section */
+
+/* Begin PBXGroup section */
+ 0017A49A2135B55400EB86D8 /* SampleAccountProviders */ = {
+ isa = PBXGroup;
+ children = (
+ 0017A4982135B53C00EB86D8 /* AADAccountProvider.h */,
+ 0017A4992135B53C00EB86D8 /* AADMSAAccountProvider.h */,
+ 0017A4962135B53C00EB86D8 /* MSAAccountProvider.h */,
+ 0017A4972135B53C00EB86D8 /* SampleAccountActionFailureReason.h */,
+ 0017A4952135B53C00EB86D8 /* SingleUserAccountProvider.h */,
+ 0017A4892135B52700EB86D8 /* AADAccountProvider.m */,
+ 0017A48C2135B52700EB86D8 /* AADMSAAccountProvider.m */,
+ 0017A48F2135B52800EB86D8 /* MSAAccountProvider.m */,
+ 0017A48B2135B52700EB86D8 /* MSATokenCache.h */,
+ 0017A48D2135B52700EB86D8 /* MSATokenCache.m */,
+ 0017A48E2135B52700EB86D8 /* MSATokenRequest.h */,
+ 0017A48A2135B52700EB86D8 /* MSATokenRequest.m */,
+ );
+ path = SampleAccountProviders;
+ sourceTree = "";
+ };
+ 00823353212F114A0055F6E4 = {
+ isa = PBXGroup;
+ children = (
+ 0017A49A2135B55400EB86D8 /* SampleAccountProviders */,
+ 0082335E212F114B0055F6E4 /* GraphNotificationsSample */,
+ 0082335D212F114B0055F6E4 /* Products */,
+ D9CF36B91E745B7CB13F10B3 /* Pods */,
+ 31E53F56A7E5C56EF7B399C4 /* Frameworks */,
+ );
+ sourceTree = "";
+ };
+ 0082335D212F114B0055F6E4 /* Products */ = {
+ isa = PBXGroup;
+ children = (
+ 0082335C212F114B0055F6E4 /* GraphNotifications.app */,
+ );
+ name = Products;
+ sourceTree = "";
+ };
+ 0082335E212F114B0055F6E4 /* GraphNotificationsSample */ = {
+ isa = PBXGroup;
+ children = (
+ 00D6D0642138519C008E5E33 /* GraphNotificationsSample.entitlements */,
+ 0017A49B2135D10A00EB86D8 /* NotificationProvider.h */,
+ 0017A49C2135D10E00EB86D8 /* NotificationProvider.m */,
+ 0082335F212F114B0055F6E4 /* AppDelegate.h */,
+ 00823360212F114B0055F6E4 /* AppDelegate.m */,
+ 00823362212F114B0055F6E4 /* RootViewController.h */,
+ 00823363212F114B0055F6E4 /* RootViewController.m */,
+ 00823365212F114B0055F6E4 /* LoginViewController.h */,
+ 00823366212F114B0055F6E4 /* LoginViewController.m */,
+ 00823368212F114B0055F6E4 /* ModelController.h */,
+ 00823369212F114B0055F6E4 /* ModelController.m */,
+ 0082336B212F114B0055F6E4 /* Main.storyboard */,
+ 0082336E212F114B0055F6E4 /* Assets.xcassets */,
+ 00823370212F114B0055F6E4 /* LaunchScreen.storyboard */,
+ 00823373212F114B0055F6E4 /* Info.plist */,
+ 00823374212F114B0055F6E4 /* main.m */,
+ 003421A72130A887007FC970 /* NotificationsManager.h */,
+ 003421A821347622007FC970 /* NotificationsManager.m */,
+ 00D6D05E21372041008E5E33 /* NotificationsViewController.h */,
+ 00D6D05F213720E5008E5E33 /* NotificationsViewController.m */,
+ 00D6D06121384BA3008E5E33 /* HistoryViewController.h */,
+ 00D6D06221384D00008E5E33 /* HistoryViewController.m */,
+ );
+ path = GraphNotificationsSample;
+ sourceTree = "";
+ };
+ 31E53F56A7E5C56EF7B399C4 /* Frameworks */ = {
+ isa = PBXGroup;
+ children = (
+ 9B6312F300B55978A0074729 /* libPods-GraphNotifications.a */,
+ );
+ name = Frameworks;
+ sourceTree = "";
+ };
+ D9CF36B91E745B7CB13F10B3 /* Pods */ = {
+ isa = PBXGroup;
+ children = (
+ FFB1B176FF8985E5AD4B88D1 /* Pods-GraphNotifications.debug.xcconfig */,
+ 63E3FBAFA254E6B80A8DA76E /* Pods-GraphNotifications.release.xcconfig */,
+ );
+ name = Pods;
+ sourceTree = "";
+ };
+/* End PBXGroup section */
+
+/* Begin PBXNativeTarget section */
+ 0082335B212F114B0055F6E4 /* GraphNotificationsSample */ = {
+ isa = PBXNativeTarget;
+ buildConfigurationList = 00823378212F114B0055F6E4 /* Build configuration list for PBXNativeTarget "GraphNotificationsSample" */;
+ buildPhases = (
+ BBBC242D2C377CDA52234B8A /* [CP] Check Pods Manifest.lock */,
+ 00823358212F114B0055F6E4 /* Sources */,
+ 00823359212F114B0055F6E4 /* Frameworks */,
+ 0082335A212F114B0055F6E4 /* Resources */,
+ 534E2B6519087712F45067D1 /* [CP] Embed Pods Frameworks */,
+ DF877AD63B2A176428A85DFC /* [CP] Copy Pods Resources */,
+ );
+ buildRules = (
+ );
+ dependencies = (
+ );
+ name = GraphNotificationsSample;
+ productName = GraphNotificationsSample;
+ productReference = 0082335C212F114B0055F6E4 /* GraphNotifications.app */;
+ productType = "com.apple.product-type.application";
+ };
+/* End PBXNativeTarget section */
+
+/* Begin PBXProject section */
+ 00823354212F114A0055F6E4 /* Project object */ = {
+ isa = PBXProject;
+ attributes = {
+ LastUpgradeCheck = 0920;
+ ORGANIZATIONNAME = Microsoft;
+ TargetAttributes = {
+ 0082335B212F114B0055F6E4 = {
+ CreatedOnToolsVersion = 9.2;
+ ProvisioningStyle = Manual;
+ SystemCapabilities = {
+ com.apple.Push = {
+ enabled = 1;
+ };
+ };
+ };
+ };
+ };
+ buildConfigurationList = 00823357212F114A0055F6E4 /* Build configuration list for PBXProject "GraphNotificationsSample" */;
+ compatibilityVersion = "Xcode 8.0";
+ developmentRegion = en;
+ hasScannedForEncodings = 0;
+ knownRegions = (
+ en,
+ Base,
+ );
+ mainGroup = 00823353212F114A0055F6E4;
+ productRefGroup = 0082335D212F114B0055F6E4 /* Products */;
+ projectDirPath = "";
+ projectRoot = "";
+ targets = (
+ 0082335B212F114B0055F6E4 /* GraphNotificationsSample */,
+ );
+ };
+/* End PBXProject section */
+
+/* Begin PBXResourcesBuildPhase section */
+ 0082335A212F114B0055F6E4 /* Resources */ = {
+ isa = PBXResourcesBuildPhase;
+ buildActionMask = 2147483647;
+ files = (
+ 00823372212F114B0055F6E4 /* LaunchScreen.storyboard in Resources */,
+ 0082336F212F114B0055F6E4 /* Assets.xcassets in Resources */,
+ 0082336D212F114B0055F6E4 /* Main.storyboard in Resources */,
+ );
+ runOnlyForDeploymentPostprocessing = 0;
+ };
+/* End PBXResourcesBuildPhase section */
+
+/* Begin PBXShellScriptBuildPhase section */
+ 534E2B6519087712F45067D1 /* [CP] Embed Pods Frameworks */ = {
+ isa = PBXShellScriptBuildPhase;
+ buildActionMask = 2147483647;
+ files = (
+ );
+ inputPaths = (
+ );
+ name = "[CP] Embed Pods Frameworks";
+ outputPaths = (
+ );
+ runOnlyForDeploymentPostprocessing = 0;
+ shellPath = /bin/sh;
+ shellScript = "\"${SRCROOT}/Pods/Target Support Files/Pods-GraphNotifications/Pods-GraphNotifications-frameworks.sh\"\n";
+ showEnvVarsInLog = 0;
+ };
+ BBBC242D2C377CDA52234B8A /* [CP] Check Pods Manifest.lock */ = {
+ isa = PBXShellScriptBuildPhase;
+ buildActionMask = 2147483647;
+ files = (
+ );
+ inputPaths = (
+ );
+ name = "[CP] Check Pods Manifest.lock";
+ outputPaths = (
+ );
+ runOnlyForDeploymentPostprocessing = 0;
+ shellPath = /bin/sh;
+ shellScript = "diff \"${PODS_ROOT}/../Podfile.lock\" \"${PODS_ROOT}/Manifest.lock\" > /dev/null\nif [ $? != 0 ] ; then\n # print error to STDERR\n echo \"error: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation.\" >&2\n exit 1\nfi\n";
+ showEnvVarsInLog = 0;
+ };
+ DF877AD63B2A176428A85DFC /* [CP] Copy Pods Resources */ = {
+ isa = PBXShellScriptBuildPhase;
+ buildActionMask = 2147483647;
+ files = (
+ );
+ inputPaths = (
+ );
+ name = "[CP] Copy Pods Resources";
+ outputPaths = (
+ );
+ runOnlyForDeploymentPostprocessing = 0;
+ shellPath = /bin/sh;
+ shellScript = "\"${SRCROOT}/Pods/Target Support Files/Pods-GraphNotifications/Pods-GraphNotifications-resources.sh\"\n";
+ showEnvVarsInLog = 0;
+ };
+/* End PBXShellScriptBuildPhase section */
+
+/* Begin PBXSourcesBuildPhase section */
+ 00823358212F114B0055F6E4 /* Sources */ = {
+ isa = PBXSourcesBuildPhase;
+ buildActionMask = 2147483647;
+ files = (
+ 0017A4922135B52800EB86D8 /* AADMSAAccountProvider.m in Sources */,
+ 00823375212F114B0055F6E4 /* main.m in Sources */,
+ 00823364212F114B0055F6E4 /* RootViewController.m in Sources */,
+ 003421A921347622007FC970 /* NotificationsManager.m in Sources */,
+ 0017A4942135B52800EB86D8 /* MSAAccountProvider.m in Sources */,
+ 00D6D06321384D00008E5E33 /* HistoryViewController.m in Sources */,
+ 0017A4932135B52800EB86D8 /* MSATokenCache.m in Sources */,
+ 0082336A212F114B0055F6E4 /* ModelController.m in Sources */,
+ 00D6D060213720E5008E5E33 /* NotificationsViewController.m in Sources */,
+ 0017A49D2135D10E00EB86D8 /* NotificationProvider.m in Sources */,
+ 0017A4902135B52800EB86D8 /* AADAccountProvider.m in Sources */,
+ 00823367212F114B0055F6E4 /* LoginViewController.m in Sources */,
+ 00823361212F114B0055F6E4 /* AppDelegate.m in Sources */,
+ 0017A4912135B52800EB86D8 /* MSATokenRequest.m in Sources */,
+ );
+ runOnlyForDeploymentPostprocessing = 0;
+ };
+/* End PBXSourcesBuildPhase section */
+
+/* Begin PBXVariantGroup section */
+ 0082336B212F114B0055F6E4 /* Main.storyboard */ = {
+ isa = PBXVariantGroup;
+ children = (
+ 0082336C212F114B0055F6E4 /* Base */,
+ );
+ name = Main.storyboard;
+ sourceTree = "";
+ };
+ 00823370212F114B0055F6E4 /* LaunchScreen.storyboard */ = {
+ isa = PBXVariantGroup;
+ children = (
+ 00823371212F114B0055F6E4 /* Base */,
+ );
+ name = LaunchScreen.storyboard;
+ sourceTree = "";
+ };
+/* End PBXVariantGroup section */
+
+/* Begin XCBuildConfiguration section */
+ 00823376212F114B0055F6E4 /* Debug */ = {
+ isa = XCBuildConfiguration;
+ buildSettings = {
+ ALWAYS_SEARCH_USER_PATHS = NO;
+ CLANG_ANALYZER_NONNULL = YES;
+ CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;
+ CLANG_CXX_LANGUAGE_STANDARD = "gnu++14";
+ CLANG_CXX_LIBRARY = "libc++";
+ CLANG_ENABLE_MODULES = YES;
+ CLANG_ENABLE_OBJC_ARC = YES;
+ CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;
+ CLANG_WARN_BOOL_CONVERSION = YES;
+ CLANG_WARN_COMMA = YES;
+ CLANG_WARN_CONSTANT_CONVERSION = YES;
+ CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
+ CLANG_WARN_DOCUMENTATION_COMMENTS = YES;
+ CLANG_WARN_EMPTY_BODY = YES;
+ CLANG_WARN_ENUM_CONVERSION = YES;
+ CLANG_WARN_INFINITE_RECURSION = YES;
+ CLANG_WARN_INT_CONVERSION = YES;
+ CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
+ CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
+ CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
+ CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
+ CLANG_WARN_STRICT_PROTOTYPES = YES;
+ CLANG_WARN_SUSPICIOUS_MOVE = YES;
+ CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
+ CLANG_WARN_UNREACHABLE_CODE = YES;
+ CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
+ CODE_SIGN_IDENTITY = "iPhone Developer";
+ COPY_PHASE_STRIP = NO;
+ DEBUG_INFORMATION_FORMAT = dwarf;
+ ENABLE_STRICT_OBJC_MSGSEND = YES;
+ ENABLE_TESTABILITY = YES;
+ GCC_C_LANGUAGE_STANDARD = gnu11;
+ GCC_DYNAMIC_NO_PIC = NO;
+ GCC_NO_COMMON_BLOCKS = YES;
+ GCC_OPTIMIZATION_LEVEL = 0;
+ GCC_PREPROCESSOR_DEFINITIONS = (
+ "DEBUG=1",
+ "$(inherited)",
+ );
+ GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
+ GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
+ GCC_WARN_UNDECLARED_SELECTOR = YES;
+ GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
+ GCC_WARN_UNUSED_FUNCTION = YES;
+ GCC_WARN_UNUSED_VARIABLE = YES;
+ IPHONEOS_DEPLOYMENT_TARGET = 11.2;
+ MTL_ENABLE_DEBUG_INFO = YES;
+ ONLY_ACTIVE_ARCH = YES;
+ SDKROOT = iphoneos;
+ };
+ name = Debug;
+ };
+ 00823377212F114B0055F6E4 /* Release */ = {
+ isa = XCBuildConfiguration;
+ buildSettings = {
+ ALWAYS_SEARCH_USER_PATHS = NO;
+ CLANG_ANALYZER_NONNULL = YES;
+ CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;
+ CLANG_CXX_LANGUAGE_STANDARD = "gnu++14";
+ CLANG_CXX_LIBRARY = "libc++";
+ CLANG_ENABLE_MODULES = YES;
+ CLANG_ENABLE_OBJC_ARC = YES;
+ CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;
+ CLANG_WARN_BOOL_CONVERSION = YES;
+ CLANG_WARN_COMMA = YES;
+ CLANG_WARN_CONSTANT_CONVERSION = YES;
+ CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
+ CLANG_WARN_DOCUMENTATION_COMMENTS = YES;
+ CLANG_WARN_EMPTY_BODY = YES;
+ CLANG_WARN_ENUM_CONVERSION = YES;
+ CLANG_WARN_INFINITE_RECURSION = YES;
+ CLANG_WARN_INT_CONVERSION = YES;
+ CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
+ CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
+ CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
+ CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
+ CLANG_WARN_STRICT_PROTOTYPES = YES;
+ CLANG_WARN_SUSPICIOUS_MOVE = YES;
+ CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
+ CLANG_WARN_UNREACHABLE_CODE = YES;
+ CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
+ CODE_SIGN_IDENTITY = "iPhone Developer";
+ COPY_PHASE_STRIP = NO;
+ DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
+ ENABLE_NS_ASSERTIONS = NO;
+ ENABLE_STRICT_OBJC_MSGSEND = YES;
+ GCC_C_LANGUAGE_STANDARD = gnu11;
+ GCC_NO_COMMON_BLOCKS = YES;
+ GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
+ GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
+ GCC_WARN_UNDECLARED_SELECTOR = YES;
+ GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
+ GCC_WARN_UNUSED_FUNCTION = YES;
+ GCC_WARN_UNUSED_VARIABLE = YES;
+ IPHONEOS_DEPLOYMENT_TARGET = 11.2;
+ MTL_ENABLE_DEBUG_INFO = NO;
+ SDKROOT = iphoneos;
+ VALIDATE_PRODUCT = YES;
+ };
+ name = Release;
+ };
+ 00823379212F114B0055F6E4 /* Debug */ = {
+ isa = XCBuildConfiguration;
+ baseConfigurationReference = FFB1B176FF8985E5AD4B88D1 /* Pods-GraphNotifications.debug.xcconfig */;
+ buildSettings = {
+ ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
+ CODE_SIGN_ENTITLEMENTS = GraphNotificationsSample/GraphNotificationsSample.entitlements;
+ "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer";
+ CODE_SIGN_STYLE = Manual;
+ DEVELOPMENT_TEAM = UBF8T346G9;
+ ENABLE_BITCODE = NO;
+ INFOPLIST_FILE = GraphNotificationsSample/Info.plist;
+ LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks";
+ LIBRARY_SEARCH_PATHS = (
+ "$(inherited)",
+ "$(PROJECT_DIR)/GraphNotificationsSample/ConnectedDevices",
+ );
+ PRODUCT_BUNDLE_IDENTIFIER = com.microsoft.connecteddevices.graphnotifications;
+ PRODUCT_NAME = "$(TARGET_NAME)";
+ PROVISIONING_PROFILE = "ba3afbc5-30e6-4150-9eb9-d8967de4ce9b";
+ PROVISIONING_PROFILE_SPECIFIER = "Graph Notifications Development";
+ TARGETED_DEVICE_FAMILY = "1,2";
+ };
+ name = Debug;
+ };
+ 0082337A212F114B0055F6E4 /* Release */ = {
+ isa = XCBuildConfiguration;
+ baseConfigurationReference = 63E3FBAFA254E6B80A8DA76E /* Pods-GraphNotifications.release.xcconfig */;
+ buildSettings = {
+ ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
+ CODE_SIGN_ENTITLEMENTS = GraphNotificationsSample/GraphNotificationsSample.entitlements;
+ "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer";
+ CODE_SIGN_STYLE = Manual;
+ DEVELOPMENT_TEAM = UBF8T346G9;
+ ENABLE_BITCODE = NO;
+ INFOPLIST_FILE = GraphNotificationsSample/Info.plist;
+ LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks";
+ LIBRARY_SEARCH_PATHS = (
+ "$(inherited)",
+ "$(PROJECT_DIR)/GraphNotificationsSample/ConnectedDevices",
+ );
+ PRODUCT_BUNDLE_IDENTIFIER = com.microsoft.connecteddevices.graphnotifications;
+ PRODUCT_NAME = "$(TARGET_NAME)";
+ PROVISIONING_PROFILE = "ba3afbc5-30e6-4150-9eb9-d8967de4ce9b";
+ PROVISIONING_PROFILE_SPECIFIER = "Graph Notifications Development";
+ TARGETED_DEVICE_FAMILY = "1,2";
+ };
+ name = Release;
+ };
+/* End XCBuildConfiguration section */
+
+/* Begin XCConfigurationList section */
+ 00823357212F114A0055F6E4 /* Build configuration list for PBXProject "GraphNotificationsSample" */ = {
+ isa = XCConfigurationList;
+ buildConfigurations = (
+ 00823376212F114B0055F6E4 /* Debug */,
+ 00823377212F114B0055F6E4 /* Release */,
+ );
+ defaultConfigurationIsVisible = 0;
+ defaultConfigurationName = Release;
+ };
+ 00823378212F114B0055F6E4 /* Build configuration list for PBXNativeTarget "GraphNotificationsSample" */ = {
+ isa = XCConfigurationList;
+ buildConfigurations = (
+ 00823379212F114B0055F6E4 /* Debug */,
+ 0082337A212F114B0055F6E4 /* Release */,
+ );
+ defaultConfigurationIsVisible = 0;
+ defaultConfigurationName = Release;
+ };
+/* End XCConfigurationList section */
+ };
+ rootObject = 00823354212F114A0055F6E4 /* Project object */;
+}
diff --git a/iOS/samples/GraphNotificationsSample/AppDelegate.h b/iOS/samples/GraphNotificationsSample/AppDelegate.h
new file mode 100644
index 0000000..9a6d78c
--- /dev/null
+++ b/iOS/samples/GraphNotificationsSample/AppDelegate.h
@@ -0,0 +1,17 @@
+//
+// AppDelegate.h
+// GraphNotifications
+//
+// Created by Allen Ballway on 8/23/18.
+// Copyright © 2018 Microsoft. All rights reserved.
+//
+
+#import
+
+@interface AppDelegate : UIResponder
+
+@property (strong, nonatomic) UIWindow *window;
+
+
+@end
+
diff --git a/iOS/samples/GraphNotificationsSample/AppDelegate.m b/iOS/samples/GraphNotificationsSample/AppDelegate.m
new file mode 100644
index 0000000..e66539c
--- /dev/null
+++ b/iOS/samples/GraphNotificationsSample/AppDelegate.m
@@ -0,0 +1,124 @@
+
+#import "AppDelegate.h"
+#import "NotificationProvider.h"
+
+
+void uncaughtExceptionHandler(NSException* uncaughtException)
+{
+ NSLog(@"Uncaught exception: %@", uncaughtException.description);
+}
+
+@interface AppDelegate ()
+
+@end
+
+@implementation AppDelegate
+
+
+- (BOOL)application:(UIApplication *)application didFinishLaunchingWithOptions:(NSDictionary *)launchOptions {
+ NSSetUncaughtExceptionHandler(&uncaughtExceptionHandler);
+
+ // Set up Notifications
+ NSDictionary* userInfo = launchOptions[UIApplicationLaunchOptionsRemoteNotificationKey];
+
+ if (!userInfo)
+ {
+ // User launch app by tapping the App icon normal launch
+ [application
+ registerUserNotificationSettings:[UIUserNotificationSettings settingsForTypes:UIUserNotificationTypeAlert categories:nil]];
+ }
+ else
+ {
+ @try
+ {
+ // app run in background and received the push notification, app is launched by user tapping the alert view
+ [MCDNotificationReceiver receiveNotification:userInfo];
+ }
+ @catch(NSException* exception) {
+ NSLog(@"Failed start up notification with exception %@", exception);
+ }
+ }
+ return YES;
+}
+
+
+- (void)applicationWillResignActive:(UIApplication *)application {
+ // Sent when the application is about to move from active to inactive state. This can occur for certain types of temporary interruptions (such as an incoming phone call or SMS message) or when the user quits the application and it begins the transition to the background state.
+ // Use this method to pause ongoing tasks, disable timers, and invalidate graphics rendering callbacks. Games should use this method to pause the game.
+}
+
+
+- (void)applicationDidEnterBackground:(UIApplication *)application {
+ // Use this method to release shared resources, save user data, invalidate timers, and store enough application state information to restore your application to its current state in case it is terminated later.
+ // If your application supports background execution, this method is called instead of applicationWillTerminate: when the user quits.
+}
+
+
+- (void)applicationWillEnterForeground:(UIApplication *)application {
+ // Called as part of the transition from the background to the active state; here you can undo many of the changes made on entering the background.
+}
+
+
+- (void)applicationDidBecomeActive:(UIApplication *)application {
+ // Restart any tasks that were paused (or not yet started) while the application was inactive. If the application was previously in the background, optionally refresh the user interface.
+}
+
+
+- (void)applicationWillTerminate:(UIApplication *)application {
+ // Called when the application is about to terminate. Save data if appropriate. See also applicationDidEnterBackground:.
+}
+
+- (void)application:(UIApplication*)application
+didRegisterUserNotificationSettings:(__unused UIUserNotificationSettings*)notificationSettings
+{
+ // actually registerForRemoteNotifications after registerUserNotificationSettings is finished
+ [application registerForRemoteNotifications];
+}
+
+- (void)application:(__unused UIApplication*)application didRegisterForRemoteNotificationsWithDeviceToken:(NSData*)deviceToken
+{
+ // when registerForRemoteNotifications, retrieve the deviceToken, convert it to HEX encoded NSString
+ NSMutableString* deviceTokenStr = [NSMutableString stringWithCapacity:deviceToken.length * 2];
+ const unsigned char* byteBuffer = deviceToken.bytes;
+ for (NSUInteger i = 0; i < deviceToken.length; ++i)
+ {
+ [deviceTokenStr appendFormat:@"%02X", (unsigned int)byteBuffer[i]];
+ }
+ NSLog(@"APNs token: %@", deviceTokenStr);
+
+ @try
+ {
+ // invoke notificationProvider with new notification registration
+ [NotificationProvider
+ updateNotificationRegistration:[[MCDNotificationRegistration alloc]
+ initWithNotificationType:MCDNotificationTypeAPN
+ token:deviceTokenStr
+ appId:[[NSBundle mainBundle] bundleIdentifier]
+ appDisplayName:(NSString*)[[NSBundle mainBundle]
+ objectForInfoDictionaryKey:@"CFBundleDisplayName"]]];
+ }
+ @catch (NSException* exception) {
+ NSLog(@"Failed to update notification registration with exception %@", exception);
+ }
+}
+
+- (void)application:(__unused UIApplication*)application didReceiveRemoteNotification:(nonnull NSDictionary*)userInfo
+{
+ // app run in foreground and received the push notification, pump notification into CDPPlatform
+ NSLog(@"Received remote notification...");
+ [userInfo enumerateKeysAndObjectsUsingBlock:^(
+ id _Nonnull key, id _Nonnull obj, __unused BOOL* _Nonnull stop) { NSLog(@"%@: %@", key, obj); }];
+ @try
+ {
+ if (![MCDNotificationReceiver receiveNotification:userInfo])
+ {
+ NSLog(@"Received notification was not for Rome");
+ }
+ }
+ @catch(NSException* exception) {
+ NSLog(@"Failed to receive notification with exception %@", exception);
+ }
+}
+
+
+@end
diff --git a/iOS/samples/GraphNotificationsSample/Assets.xcassets/AppIcon.appiconset/Contents.json b/iOS/samples/GraphNotificationsSample/Assets.xcassets/AppIcon.appiconset/Contents.json
new file mode 100644
index 0000000..d8db8d6
--- /dev/null
+++ b/iOS/samples/GraphNotificationsSample/Assets.xcassets/AppIcon.appiconset/Contents.json
@@ -0,0 +1,98 @@
+{
+ "images" : [
+ {
+ "idiom" : "iphone",
+ "size" : "20x20",
+ "scale" : "2x"
+ },
+ {
+ "idiom" : "iphone",
+ "size" : "20x20",
+ "scale" : "3x"
+ },
+ {
+ "idiom" : "iphone",
+ "size" : "29x29",
+ "scale" : "2x"
+ },
+ {
+ "idiom" : "iphone",
+ "size" : "29x29",
+ "scale" : "3x"
+ },
+ {
+ "idiom" : "iphone",
+ "size" : "40x40",
+ "scale" : "2x"
+ },
+ {
+ "idiom" : "iphone",
+ "size" : "40x40",
+ "scale" : "3x"
+ },
+ {
+ "idiom" : "iphone",
+ "size" : "60x60",
+ "scale" : "2x"
+ },
+ {
+ "idiom" : "iphone",
+ "size" : "60x60",
+ "scale" : "3x"
+ },
+ {
+ "idiom" : "ipad",
+ "size" : "20x20",
+ "scale" : "1x"
+ },
+ {
+ "idiom" : "ipad",
+ "size" : "20x20",
+ "scale" : "2x"
+ },
+ {
+ "idiom" : "ipad",
+ "size" : "29x29",
+ "scale" : "1x"
+ },
+ {
+ "idiom" : "ipad",
+ "size" : "29x29",
+ "scale" : "2x"
+ },
+ {
+ "idiom" : "ipad",
+ "size" : "40x40",
+ "scale" : "1x"
+ },
+ {
+ "idiom" : "ipad",
+ "size" : "40x40",
+ "scale" : "2x"
+ },
+ {
+ "idiom" : "ipad",
+ "size" : "76x76",
+ "scale" : "1x"
+ },
+ {
+ "idiom" : "ipad",
+ "size" : "76x76",
+ "scale" : "2x"
+ },
+ {
+ "idiom" : "ipad",
+ "size" : "83.5x83.5",
+ "scale" : "2x"
+ },
+ {
+ "idiom" : "ios-marketing",
+ "size" : "1024x1024",
+ "scale" : "1x"
+ }
+ ],
+ "info" : {
+ "version" : 1,
+ "author" : "xcode"
+ }
+}
\ No newline at end of file
diff --git a/iOS/samples/GraphNotificationsSample/Base.lproj/LaunchScreen.storyboard b/iOS/samples/GraphNotificationsSample/Base.lproj/LaunchScreen.storyboard
new file mode 100644
index 0000000..f83f6fd
--- /dev/null
+++ b/iOS/samples/GraphNotificationsSample/Base.lproj/LaunchScreen.storyboard
@@ -0,0 +1,25 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/iOS/samples/GraphNotificationsSample/Base.lproj/Main.storyboard b/iOS/samples/GraphNotificationsSample/Base.lproj/Main.storyboard
new file mode 100644
index 0000000..4d46933
--- /dev/null
+++ b/iOS/samples/GraphNotificationsSample/Base.lproj/Main.storyboard
@@ -0,0 +1,129 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
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