diff --git a/AndroidManifest.xml b/AndroidManifest.xml index 982344e..5328d2e 100644 --- a/AndroidManifest.xml +++ b/AndroidManifest.xml @@ -14,6 +14,12 @@ + + + + + + + + + + + + + diff --git a/README.md b/README.md index 1188f7e..11987c1 100644 --- a/README.md +++ b/README.md @@ -15,6 +15,7 @@ The following instructions are tested for Intellij on Mac OS X and Windows: 1. Ensure you have [the latest Android SDKs and build tools installed](https://developer.android.com/sdk/index.html). +1. Install the [Google Play Services SDK](http://developer.android.com/google/play-services/setup.html#Install). 1. Create a file in the project root directory called `local.properties` and add the line `sdk.dir=/path/to/your/sdk`. 1. Download [Gradle](http://www.gradle.org/downloads) (tested with version 1.10). 1. Open Intellij (or Android Studio). Ensure you have the Gradle Intellij plugin. diff --git a/build.gradle b/build.gradle index f80ef7c..03612f0 100644 --- a/build.gradle +++ b/build.gradle @@ -42,6 +42,7 @@ dependencies { compile 'com.nostra13.universalimageloader:universal-image-loader:1.9.1' compile "com.mixpanel.android:mixpanel-android:4.0.0@aar" compile 'com.crashlytics.android:crashlytics:1.+' + compile 'com.google.android.gms:play-services:4.0.30' } android { diff --git a/proguard-project.txt b/proguard-project.txt index 492ca2c..c7f9bf7 100644 --- a/proguard-project.txt +++ b/proguard-project.txt @@ -42,3 +42,22 @@ # We're open-sourced anyway... let's make life easier :) -dontobfuscate + +# Don't strip away required classes from Google Play Services SDK. +# From http://developer.android.com/google/play-services/setup.html +-keep class * extends java.util.ListResourceBundle { + protected Object[][] getContents(); +} + +-keep public class com.google.android.gms.common.internal.safeparcel.SafeParcelable { + public static final *** NULL; +} + +-keepnames @com.google.android.gms.common.annotation.KeepName class * +-keepclassmembernames class * { + @com.google.android.gms.common.annotation.KeepName *; +} + +-keepnames class * implements android.os.Parcelable { + public static final ** CREATOR; +} diff --git a/res/drawable-xhdpi/ic_add_alert.png b/res/drawable-xhdpi/ic_add_alert.png new file mode 100644 index 0000000..e1ea093 Binary files /dev/null and b/res/drawable-xhdpi/ic_add_alert.png differ diff --git a/res/drawable-xhdpi/ic_notification.png b/res/drawable-xhdpi/ic_notification.png new file mode 100644 index 0000000..c835319 Binary files /dev/null and b/res/drawable-xhdpi/ic_notification.png differ diff --git a/res/layout/course_class_row_item.xml b/res/layout/course_class_row_item.xml index 73039ab..55a57ca 100644 --- a/res/layout/course_class_row_item.xml +++ b/res/layout/course_class_row_item.xml @@ -1,6 +1,46 @@ - - + android:layout_width="match_parent" + android:layout_height="wrap_content" + android:paddingTop="3dp" + android:paddingBottom="3dp" + android:paddingLeft="10dp" + android:paddingRight="10dp"> + + + + + + diff --git a/res/layout/course_schedule.xml b/res/layout/course_schedule.xml index 5e9f1ae..eecdda9 100644 --- a/res/layout/course_schedule.xml +++ b/res/layout/course_schedule.xml @@ -24,26 +24,6 @@ android:layout_weight="1" android:layout_width="match_parent" android:layout_height="0dp" > - - - - - - - - - - diff --git a/src/com/uwflow/flow_android/MainFlowActivity.java b/src/com/uwflow/flow_android/MainFlowActivity.java index 422ef1a..41cafec 100644 --- a/src/com/uwflow/flow_android/MainFlowActivity.java +++ b/src/com/uwflow/flow_android/MainFlowActivity.java @@ -34,6 +34,8 @@ import com.uwflow.flow_android.network.FlowAsyncClient; import com.uwflow.flow_android.nfc.SharableURL; import com.uwflow.flow_android.util.FacebookUtilities; +import com.uwflow.flow_android.util.RegistrationIdUtil; +import org.apache.commons.lang3.StringUtils; import org.json.JSONException; import org.json.JSONObject; @@ -154,8 +156,12 @@ public void onDrawerOpened(View view) { mDrawerList.setOnItemClickListener(new DrawerItemClickListener()); if (savedInstanceState == null) { - Fragment initialFragment; - if (isUserLoggedIn) { + Bundle bundle = getIntent().getExtras(); + String courseId = (bundle == null ? null : bundle.getString(Constants.COURSE_ID_KEY)); + + if (StringUtils.isNotEmpty(courseId)) { + replaceWithFragment(CourseFragment.newInstance(courseId), false); + } else if (isUserLoggedIn) { selectItem(Constants.NAV_DRAWER_PROFILE_INDEX, false); } else { selectItem(Constants.NAV_DRAWER_EXPLORE_INDEX, false); @@ -198,6 +204,8 @@ public NdefMessage createNdefMessage(NfcEvent event) { }, this); } + // Register this device with GCM to receive push notifications, if possible. + RegistrationIdUtil.init(getApplicationContext()); } /** @@ -339,6 +347,18 @@ private void selectItem(int itemID, boolean addToBackStack) { return; } + replaceWithFragment(fragment, addToBackStack); + int selectedPosition = mNavDrawerAdapter.getPositionFromId(itemID); + if (selectedPosition >= 0) { + mDrawerList.setItemChecked(selectedPosition, true); + } + mDrawerLayout.closeDrawer(mDrawerContainer); + } + + /** + * Adds the given fragment to the back stack and replaces the current one with it. + */ + private void replaceWithFragment(Fragment fragment, boolean addToBackStack) { FragmentManager fragmentManager = getSupportFragmentManager(); FragmentTransaction transaction = fragmentManager.beginTransaction() .replace(R.id.content_frame, fragment); @@ -346,13 +366,9 @@ private void selectItem(int itemID, boolean addToBackStack) { transaction.addToBackStack(null); } transaction.commit(); - int selectedPosition = mNavDrawerAdapter.getPositionFromId(itemID); - if (selectedPosition >= 0) { - mDrawerList.setItemChecked(selectedPosition, true); - } - mDrawerLayout.closeDrawer(mDrawerContainer); } + private void handleNfcIntent(Intent intent) { if (intent == null) return; diff --git a/src/com/uwflow/flow_android/adapters/CourseClassListAdapter.java b/src/com/uwflow/flow_android/adapters/CourseClassListAdapter.java index 9f96824..53bd44c 100644 --- a/src/com/uwflow/flow_android/adapters/CourseClassListAdapter.java +++ b/src/com/uwflow/flow_android/adapters/CourseClassListAdapter.java @@ -2,23 +2,39 @@ import android.content.Context; import android.text.Html; +import android.util.Log; import android.view.LayoutInflater; import android.view.View; import android.view.ViewGroup; import android.widget.BaseAdapter; +import android.widget.ImageButton; import android.widget.TextView; +import android.widget.Toast; +import com.crashlytics.android.Crashlytics; import com.uwflow.flow_android.R; import com.uwflow.flow_android.db_object.Meeting; import com.uwflow.flow_android.db_object.Section; +import com.uwflow.flow_android.network.FlowApiRequestCallback; +import com.uwflow.flow_android.network.FlowApiRequests; +import com.uwflow.flow_android.util.CourseUtil; +import com.uwflow.flow_android.util.RegistrationIdUtil; import com.uwflow.flow_android.util.StringHelper; +import com.uwflow.flow_android.util.UserUtil; +import org.apache.commons.lang3.StringUtils; +import org.json.JSONObject; +import java.text.SimpleDateFormat; import java.util.ArrayList; +import java.util.Calendar; +import java.util.LinkedList; import java.util.List; /** * Created by jasperfung on 2/22/14. */ public class CourseClassListAdapter extends BaseAdapter { + private static final String TAG = CourseClassListAdapter.class.getSimpleName(); + private List
mClasses; private Context mContext; @@ -51,70 +67,117 @@ public View getView(int position, View convertView, ViewGroup parent) { } // Fill view with appropriate data - TextView column1, column2; + TextView column1, column2, seatsAvailableTextView; column1 = (TextView) convertView.findViewById(R.id.col1); column2 = (TextView) convertView.findViewById(R.id.col2); + seatsAvailableTextView = (TextView) convertView.findViewById(R.id.seats_available_textview); + final ImageButton addAlertButton = (ImageButton) convertView.findViewById(R.id.add_alert_button); - Section currClass = mClasses.get(position); - + final Section currClass = mClasses.get(position); // Populate first column (class info) String sectionType = currClass.getSectionType(); String sectionNumber = currClass.getSectionNum(); - Meeting meeting = currClass.getMeetings().get(0); // TODO: find out if we should ever handle >1 meeting - String professor = meeting.getProfId(); - if (professor != null) { - professor = StringHelper.capitalize(professor.replaceAll("_", " ")); - } - int enrollmentTotal = currClass.getEnrollmentTotal(); - int enrollmentCapacity = currClass.getEnrollmentCapacity(); + Meeting meeting = currClass.getMeetings().get(0); // TODO: Handle more than 1 meeting // Make sure we don't have nulls - if (sectionType == null) sectionType = "---"; - if (sectionNumber == null) sectionNumber = "---"; - if (professor == null) professor = "---"; - - String string1 = String.format("%s %s\n%s\nSeats: %d/%d", - sectionType, - sectionNumber, - professor, - enrollmentTotal, - enrollmentCapacity); - column1.setText(string1); + if (sectionType == null) sectionType = ""; + if (sectionNumber == null) sectionNumber = ""; + column1.setText(String.format("%s\n%s", sectionType, sectionNumber)); - // Populate second column (time and location) - String string2; + // Populate second column (class details) + List detailsList = new LinkedList(); String building = meeting.getBuilding(); String room = meeting.getRoom(); String campus = currClass.getCampus(); + if (campus == null) campus = ""; + String professor = meeting.getProfId(); - String time; long startSeconds = meeting.getStartSeconds(); long endSeconds = meeting.getEndSeconds(); - if (startSeconds == 0 || endSeconds == 0) { - time = "N/A"; - } else { - time = String.format("%s - %s", + if (startSeconds != 0 && endSeconds != 0) { + detailsList.add(String.format("%s - %s", getTimeFromSeconds(startSeconds), - getTimeFromSeconds(endSeconds)); + getTimeFromSeconds(endSeconds))); } - if (campus == null) campus = ""; - if (building == null || room == null) { - string2= String.format("%s
%s
%s", - time, - getFormattedDays(meeting.getDays()), - campus); + if (meeting.getDays() != null && !meeting.getDays().isEmpty()) { + detailsList.add(getFormattedDays(meeting.getDays())); + } + if (StringUtils.isNotEmpty(building) || StringUtils.isNotEmpty(room)) { + detailsList.add(String.format("%s %s - %s", building, room, campus)); + } else if (StringUtils.isNotEmpty(campus)) { + detailsList.add(campus); + } + if (StringUtils.isNotEmpty(professor)) { + professor = StringHelper.capitalize(professor.replaceAll("_", " ")); + detailsList.add(professor); + } + column2.setText(Html.fromHtml(StringUtils.join(detailsList, "
"))); + + // Populate seats available column + int enrollmentTotal = currClass.getEnrollmentTotal(); + int enrollmentCapacity = currClass.getEnrollmentCapacity(); + String fractionHtml = String.format("%s/%s
seats", enrollmentTotal, enrollmentCapacity); + seatsAvailableTextView.setText(Html.fromHtml(fractionHtml)); + + // Enable notification subscription button for at-capacity classes + // TODO(david): Would be good to change button styling if alert already added + // TODO(david): Change back to checkbox (and style it properly) if above is done. + final String registrationId = RegistrationIdUtil.getRegistrationId(mContext); + if (RegistrationIdUtil.supportsGcm(mContext) && StringUtils.isNotEmpty(registrationId) && + enrollmentTotal >= enrollmentCapacity) { + addAlertButton.setVisibility(View.VISIBLE); } else { - string2= String.format("%s
%s
%s %s - %s", - time, - getFormattedDays(meeting.getDays()), - building, - room, - campus); + addAlertButton.setVisibility(View.INVISIBLE); } - column2.setText(Html.fromHtml(string2)); + + final String finalSectionType = sectionType; + final String finalSectionNumber = sectionNumber; + final String userId = UserUtil.getLoggedInUserId(mContext); + + addAlertButton.setOnClickListener(new View.OnClickListener() { + @Override + public void onClick(View v) { + final String courseId = currClass.getCourseId(); + final String humanizedCourseId = CourseUtil.humanizeCourseId(courseId); + final String termId = currClass.getTermId(); + String registrationId = RegistrationIdUtil.getRegistrationId(mContext); + + addAlertButton.setEnabled(false); + + FlowApiRequests.addCourseAlert(registrationId, courseId, termId, finalSectionType, finalSectionNumber, + userId, new FlowApiRequestCallback() { + @Override + public void onSuccess(JSONObject response) { + String message = String.format( + "You will be notified when a seat opens up for %s: %s %s.", + humanizedCourseId, finalSectionType, finalSectionNumber); + Toast.makeText(mContext, message, Toast.LENGTH_LONG).show(); + } + + @Override + public void onFailure(String error) { + String message; + + // TODO(david): Server should return error code instead + if (StringUtils.containsIgnoreCase(error, "already exists")) { + message = String.format("You've already added an alert for %s: %s %s", + humanizedCourseId, finalSectionType, finalSectionNumber); + } else { + message = String.format("Uh oh, could not add a course alert for %s. Error: %s", + humanizedCourseId, error); + Log.d(TAG, message); + Crashlytics.log(Log.ERROR, TAG, message); + } + + Toast.makeText(mContext, message, Toast.LENGTH_LONG).show(); + addAlertButton.setEnabled(true); + } + }); + } + }); return convertView; } @@ -122,12 +185,11 @@ public View getView(int position, View convertView, ViewGroup parent) { private String getTimeFromSeconds(long seconds) { int hours = (int)(seconds / 3600); int minutes = (int)(seconds % 3600) / 60; - if (hours >= 12 && minutes >= 0) { - if (hours > 12) hours -= 12; - return String.format("%d:%dpm", hours, minutes); - } else { - return String.format("%d:%dam", hours, minutes); - } + Calendar cal = Calendar.getInstance(); + cal.set(Calendar.HOUR_OF_DAY, hours); + cal.set(Calendar.MINUTE, minutes); + + return new SimpleDateFormat("h:mma").format(cal.getTime()); } private String getFormattedDays(ArrayList days) { diff --git a/src/com/uwflow/flow_android/adapters/ProfilePagerAdapter.java b/src/com/uwflow/flow_android/adapters/ProfilePagerAdapter.java index c8eff1d..2bc5bdf 100644 --- a/src/com/uwflow/flow_android/adapters/ProfilePagerAdapter.java +++ b/src/com/uwflow/flow_android/adapters/ProfilePagerAdapter.java @@ -17,8 +17,8 @@ public class ProfilePagerAdapter extends FragmentStatePagerAdapter { private static final int USER_FRIEND_PROFILE_TAB_NUMBER = 3; private static final String[] TITLES = new String[]{ - "Schedule", "Courses", + "Schedule", "Exams", "Friends", }; diff --git a/src/com/uwflow/flow_android/adapters/ProfileScheduleAdapter.java b/src/com/uwflow/flow_android/adapters/ProfileScheduleAdapter.java index 0eefc8d..eebf8ab 100644 --- a/src/com/uwflow/flow_android/adapters/ProfileScheduleAdapter.java +++ b/src/com/uwflow/flow_android/adapters/ProfileScheduleAdapter.java @@ -5,17 +5,17 @@ import android.content.Context; import android.graphics.Typeface; import android.support.v4.app.FragmentManager; -import android.support.v4.app.FragmentTransaction; -import android.util.Log; import android.view.LayoutInflater; import android.view.View; import android.view.ViewGroup; -import android.widget.*; import com.uwflow.flow_android.FlowApplication; +import android.widget.BaseExpandableListAdapter; +import android.widget.ImageButton; +import android.widget.TableLayout; +import android.widget.TextView; import com.uwflow.flow_android.R; -import com.uwflow.flow_android.constant.Constants; -import com.uwflow.flow_android.db_object.*; -import com.uwflow.flow_android.fragment.CourseFragment; +import com.uwflow.flow_android.db_object.ScheduleCourse; +import com.uwflow.flow_android.db_object.ScheduleCourses; import com.uwflow.flow_android.util.CalendarHelper; import com.uwflow.flow_android.util.CourseUtil; @@ -155,7 +155,6 @@ public View getChildView(final int groupPosition, final int childPosition, addAlarmButton.setOnClickListener(new View.OnClickListener() { @Override public void onClick(View v) { - // TODO: Add an alarm for the current course mContext.startActivity(CalendarHelper.getAddAlarmIntent(title, location, startDate)); } }); diff --git a/src/com/uwflow/flow_android/broadcast_receiver/GcmBroadcastReceiver.java b/src/com/uwflow/flow_android/broadcast_receiver/GcmBroadcastReceiver.java new file mode 100644 index 0000000..778d364 --- /dev/null +++ b/src/com/uwflow/flow_android/broadcast_receiver/GcmBroadcastReceiver.java @@ -0,0 +1,110 @@ +package com.uwflow.flow_android.broadcast_receiver; + +import android.app.Activity; +import android.app.Notification; +import android.app.NotificationManager; +import android.app.PendingIntent; +import android.content.BroadcastReceiver; +import android.content.Context; +import android.content.Intent; +import android.os.Bundle; +import android.support.v4.app.NotificationCompat; +import android.support.v4.app.TaskStackBuilder; +import android.util.Log; +import com.crashlytics.android.Crashlytics; +import com.google.android.gms.gcm.GoogleCloudMessaging; +import com.uwflow.flow_android.MainFlowActivity; +import com.uwflow.flow_android.R; +import com.uwflow.flow_android.constant.Constants; +import org.json.JSONException; +import org.json.JSONObject; + +import java.util.Random; + +/** + * Receives push notifications from GCM. + * + * This does not use a WakefulBroadcastReceiver and thus cannot take long to process. If more processing time + * is needed, take a look at http://developer.android.com/google/gcm/client.html for extending WakefulBroadcastReceiver. + */ +public class GcmBroadcastReceiver extends BroadcastReceiver { + private final String TAG = GcmBroadcastReceiver.class.getSimpleName(); + + @Override + public void onReceive(Context context, Intent intent) { + Bundle extras = intent.getExtras(); + GoogleCloudMessaging gcm = GoogleCloudMessaging.getInstance(context); + String messageType = gcm.getMessageType(intent); + + if (!extras.isEmpty()) { + if (GoogleCloudMessaging.MESSAGE_TYPE_SEND_ERROR.equals(messageType)) { + String errorMessage = "Push notification send error: " + extras.toString(); + Crashlytics.log(Log.ERROR, TAG, errorMessage); + Log.e(TAG, errorMessage); + } else if (GoogleCloudMessaging.MESSAGE_TYPE_DELETED.equals(messageType)) { + String errorMessage = "Deleted push notifications on server: " + extras.toString(); + Crashlytics.log(Log.ERROR, TAG, errorMessage); + Log.e(TAG, errorMessage); + } else if (GoogleCloudMessaging.MESSAGE_TYPE_MESSAGE.equals(messageType)) { + // Actually a regular message. Handle it. + Log.i(TAG, "Received push notification " + extras.toString()); + displayNotification(context, extras); + } + } + + setResultCode(Activity.RESULT_OK); + } + + /** + * Notify a push notification received from GCM. + * This is currently only configured to receive course class seat opening alerts. + * @param data + */ + private void displayNotification(Context context, Bundle data) { + String courseName = ""; + String courseId = ""; + String courseCode = ""; + + try { + JSONObject course = new JSONObject(data.getString("course")); + courseName = course.getString("name"); + courseId = course.getString("id"); + courseCode = course.getString("code"); + } catch (JSONException e) { + e.printStackTrace(); + Crashlytics.logException(e); + } + + // TODO(david): Look into why this doesn't go to the right course if Flow is already started + Intent resultIntent = new Intent(context, MainFlowActivity.class); + resultIntent.putExtra(Constants.COURSE_ID_KEY, courseId); + + // The stack builder object will contain an artificial back stack for the started Activity. + // This ensures that navigating backward from the Activity leads out of the app to the Home screen. + TaskStackBuilder stackBuilder = TaskStackBuilder.create(context); + // Adds the back stack for the Intent (but not the Intent itself) + stackBuilder.addParentStack(MainFlowActivity.class); + // Adds the Intent that starts the Activity to the top of the stack + stackBuilder.addNextIntent(resultIntent); + + PendingIntent contentIntent = stackBuilder.getPendingIntent(0, PendingIntent.FLAG_UPDATE_CURRENT); + + String title = String.format("%s: spots open!", courseCode); + String text = String.format("%s: %s has seats open now.", courseCode, courseName); + + Notification notification = new NotificationCompat.Builder(context) + .setSmallIcon(R.drawable.ic_notification) + .setContentTitle(title) + .setContentText(text) + .setContentIntent(contentIntent) + .setAutoCancel(true) + .setPriority(Notification.PRIORITY_HIGH) + .setDefaults(Notification.DEFAULT_ALL) + .build(); + + NotificationManager notificationManager = (NotificationManager) context.getSystemService( + Context.NOTIFICATION_SERVICE); + notificationManager.notify(new Random().nextInt(), notification); + } + +} diff --git a/src/com/uwflow/flow_android/constant/Constants.java b/src/com/uwflow/flow_android/constant/Constants.java index ffb64ac..c9aa6a2 100644 --- a/src/com/uwflow/flow_android/constant/Constants.java +++ b/src/com/uwflow/flow_android/constant/Constants.java @@ -28,6 +28,7 @@ public class Constants { public static final String API_USERS_FRIENDS = "api/v1/users/%s/friends"; public static final String API_USER_SHORTLIST_COURSE = "api/v1/user/shortlist/%s"; public static final String API_SEARCH_COURSES = "api/v1/search/courses?%s"; + public static final String API_GCM_COURSE_ALERTS = "api/v1/alerts/course/gcm"; public static class DatabaseColumnName{ public static final String USER_ID = "user_id"; @@ -84,8 +85,8 @@ public static class BroadcastActionId { public static final int COURSE_ABOUT_PAGE_INDEX = 1; public static final int COURSE_REVIEWS_PAGE_INDEX = 2; - public static final int PROFILE_SCHEDULE_PAGE_INDEX = 0; - public static final int PROFILE_COURSES_PAGE_INDEX = 1; + public static final int PROFILE_COURSES_PAGE_INDEX = 0; + public static final int PROFILE_SCHEDULE_PAGE_INDEX = 1; public static final int PROFILE_EXAMS_PAGE_INDEX = 2; public static final int PROFILE_FRIENDS_PAGE_INDEX = 3; @@ -103,4 +104,6 @@ public static class BroadcastActionId { public static final String SHORTLIST_TERM_ID = "9999_99"; + public static final String APP_VERSION_KEY = "app_version"; + } diff --git a/src/com/uwflow/flow_android/fragment/CourseScheduleFragment.java b/src/com/uwflow/flow_android/fragment/CourseScheduleFragment.java index 4cad7b9..90c9d31 100644 --- a/src/com/uwflow/flow_android/fragment/CourseScheduleFragment.java +++ b/src/com/uwflow/flow_android/fragment/CourseScheduleFragment.java @@ -127,13 +127,14 @@ private View createScheduleTermHeader(String heading) { TextView textView = new TextView(getActivity()); textView.setText(heading); textView.setTypeface(null, Typeface.BOLD); + textView.setTextColor(getResources().getColor(R.color.white)); textView.setLayoutParams(new TableLayout.LayoutParams( TableLayout.LayoutParams.MATCH_PARENT, TableLayout.LayoutParams.WRAP_CONTENT, 1f)); float scale = getResources().getDisplayMetrics().density; // scale for converting dp to px - textView.setPadding((int)(10 * scale + 0.5f), 0, 0, 0); - textView.setBackgroundResource(R.color.flow_light_blue); + textView.setPadding((int)(10 * scale + 0.5f), 8, 0, 8); + textView.setBackgroundResource(R.color.flow_blue); return textView; } diff --git a/src/com/uwflow/flow_android/network/FlowApiRequestCallback.java b/src/com/uwflow/flow_android/network/FlowApiRequestCallback.java index 284d01c..8a8616a 100644 --- a/src/com/uwflow/flow_android/network/FlowApiRequestCallback.java +++ b/src/com/uwflow/flow_android/network/FlowApiRequestCallback.java @@ -3,8 +3,6 @@ import com.uwflow.flow_android.db_object.*; import org.json.JSONObject; -import java.util.ArrayList; - public abstract class FlowApiRequestCallback { abstract public void onSuccess(JSONObject response); abstract public void onFailure(String error); diff --git a/src/com/uwflow/flow_android/network/FlowApiRequests.java b/src/com/uwflow/flow_android/network/FlowApiRequests.java index b71e19b..56b8be8 100644 --- a/src/com/uwflow/flow_android/network/FlowApiRequests.java +++ b/src/com/uwflow/flow_android/network/FlowApiRequests.java @@ -17,6 +17,7 @@ import com.uwflow.flow_android.constant.Constants; import com.uwflow.flow_android.db_object.*; import com.uwflow.flow_android.util.JsonToDbUtil; +import com.uwflow.flow_android.util.RegistrationIdUtil; import org.apache.commons.lang3.StringUtils; import org.apache.http.Header; import org.json.JSONException; @@ -77,6 +78,59 @@ public void onFailure(Throwable e, JSONObject errorResponse) { }); } + public static void addCourseAlert(String registrationId, final String courseId, String termId, String sectionType, + String sectionNum, String userId, final FlowApiRequestCallback callback) { + RequestParams params = new RequestParams(); + params.put("registration_id", registrationId); + params.put("course_id", courseId); + + if (StringUtils.isNotEmpty(termId)) { + params.put("term_id", termId); + } + if (StringUtils.isNotEmpty(sectionType)) { + params.put("section_type", sectionType); + } + if (StringUtils.isNotEmpty(sectionNum)) { + params.put("section_num", sectionNum); + } + if (StringUtils.isNotEmpty(userId)) { + params.put("user_id", userId); + } + + FlowAsyncClient.post(Constants.API_GCM_COURSE_ALERTS, params, new JsonHttpResponseHandler() { + @Override + public void onSuccess(JSONObject response) { + Log.d(TAG, "Add alert for course " + courseId + " success."); + callback.onSuccess(response); + } + + @Override + public void onFailure(String responseBody, Throwable error) { + Log.d(TAG, "Add alert for course " + courseId + " failed."); + callback.onFailure(responseBody); + } + + @Override + public void onFailure(Throwable e, JSONObject errorResponse) { + Log.d(TAG, "Add alert for course " + courseId + " failed."); + + if (errorResponse == null) { + callback.onFailure(null); + return; + } + + String errorMessage = ""; + try { + errorMessage = errorResponse.getString("error"); + } catch (JSONException e1) { + // Ignore could not parse error message + } + + callback.onFailure(errorMessage); + } + }); + } + public static void searchCourses(String keywords, String sortMode, Boolean excludeTakenCourses, Integer count, Integer offset, final FlowApiRequestCallback callback) { // Build the search query string diff --git a/src/com/uwflow/flow_android/network/FlowDatabaseLoader.java b/src/com/uwflow/flow_android/network/FlowDatabaseLoader.java index 9ea6b56..b82063a 100644 --- a/src/com/uwflow/flow_android/network/FlowDatabaseLoader.java +++ b/src/com/uwflow/flow_android/network/FlowDatabaseLoader.java @@ -1,23 +1,23 @@ package com.uwflow.flow_android.network; import android.content.Context; -import android.content.SharedPreferences; import android.os.AsyncTask; -import android.preference.PreferenceManager; import com.crashlytics.android.Crashlytics; import com.j256.ormlite.dao.Dao; import com.j256.ormlite.stmt.QueryBuilder; -import com.uwflow.flow_android.adapters.ProfileScheduleAdapter; import com.uwflow.flow_android.broadcast_receiver.BroadcastFactory; -import com.uwflow.flow_android.constant.Constants; import com.uwflow.flow_android.dao.FlowDatabaseHelper; import com.uwflow.flow_android.db_object.*; import com.uwflow.flow_android.util.CalendarHelper; import com.uwflow.flow_android.util.JsonToDbUtil; +import com.uwflow.flow_android.util.UserUtil; import org.json.JSONObject; import java.sql.SQLException; -import java.util.*; +import java.util.Collections; +import java.util.Comparator; +import java.util.Date; +import java.util.List; import java.util.concurrent.Callable; /** @@ -29,13 +29,11 @@ public class FlowDatabaseLoader { protected FlowDatabaseHelper flowDatabaseHelper; protected Context context; protected FlowImageLoader flowImageLoader; - protected SharedPreferences sp; public FlowDatabaseLoader(Context context, FlowDatabaseHelper flowDatabaseHelper) { this.context = context; this.flowDatabaseHelper = flowDatabaseHelper; this.flowImageLoader = new FlowImageLoader(context); - sp = PreferenceManager.getDefaultSharedPreferences(context); } /** @@ -88,9 +86,7 @@ protected Void doInBackground(JSONObject... jsonObjects) { if (user != null && user.getProfilePicUrls() != null) flowImageLoader.preloadImage(user.getProfilePicUrls().getLarge()); if (user != null) { - SharedPreferences.Editor editor = sp.edit(); - editor.putString(Constants.PROFILE_ID_KEY, user.getId()); - editor.commit(); + UserUtil.saveLoggedInUserId(context, user.getId()); userDao.createOrUpdate(user); } } catch (SQLException e) { @@ -127,11 +123,12 @@ public void updateOrCreateUserScheduleImage(ScheduleImage scheduleImage) { return; } if (scheduleImage.getId() == null) { - String id = sp.getString(Constants.PROFILE_ID_KEY, null); - if (id == null) + String id = UserUtil.getLoggedInUserId(context); + if (id == null) { return; - else - scheduleImage.setId(id); + } + + scheduleImage.setId(id); } new AsyncTask() { @Override @@ -390,7 +387,7 @@ protected ScheduleImage doInBackground(String... strings) { try { String arg = strings[0]; if (arg == null) { - arg = sp.getString(Constants.PROFILE_ID_KEY, null); + arg = UserUtil.getLoggedInUserId(context); if (arg == null) return null; } Dao scheduleImageStringDao = flowDatabaseHelper.getUserSchduleImageDao(); diff --git a/src/com/uwflow/flow_android/util/RegistrationIdUtil.java b/src/com/uwflow/flow_android/util/RegistrationIdUtil.java new file mode 100644 index 0000000..f8222e3 --- /dev/null +++ b/src/com/uwflow/flow_android/util/RegistrationIdUtil.java @@ -0,0 +1,135 @@ +package com.uwflow.flow_android.util; + +import android.content.Context; +import android.content.SharedPreferences; +import android.content.pm.PackageInfo; +import android.content.pm.PackageManager; +import android.os.AsyncTask; +import android.preference.PreferenceManager; +import android.util.Log; +import com.crashlytics.android.Crashlytics; +import com.google.android.gms.common.ConnectionResult; +import com.google.android.gms.common.GooglePlayServicesUtil; +import com.google.android.gms.gcm.GoogleCloudMessaging; +import com.uwflow.flow_android.constant.Constants; +import org.apache.commons.lang3.StringUtils; + +import java.io.IOException; + +/** + * Helper methods for dealing with registration IDs used for push notifications from GCM. + * Mostly adapted from http://developer.android.com/google/gcm/client.html + */ +public class RegistrationIdUtil { + private static final String TAG = RegistrationIdUtil.class.getSimpleName(); + + // Since this has to be shipped with APK, there is no need to keep this secret. + // Also http://stackoverflow.com/questions/18196292/what-are-consequences-of-having-gcm-sender-id-being-exposed + private static final String GCM_SENDER_ID = "914611125417"; + + private static final String REGISTRATION_ID_KEY = "registration_id"; + + private static GoogleCloudMessaging mGcm = null; + private static String mRegistrationId = null; + + /** + * Register this device with GCM to receive push notifications, if possible. + * @param context + */ + public static void init(Context context) { + if (supportsGcm(context)) { + mGcm = GoogleCloudMessaging.getInstance(context); + mRegistrationId = getRegistrationId(context); + + if (StringUtils.isEmpty(mRegistrationId)) { + registerInBackground(context); + } + Log.i(TAG, "Registration ID is " + mRegistrationId); + } else { + Log.w(TAG, "Google Play Services SDK not available. Cannot receive push notifications."); + } + } + + public static boolean supportsGcm(Context context) { + return GooglePlayServicesUtil.isGooglePlayServicesAvailable(context) == ConnectionResult.SUCCESS; + } + + /** + * Get the current registration ID from shared preferences. + * + * @return The registration ID if available and valid; null otherwise. + */ + public static String getRegistrationId(Context context) { + SharedPreferences preferences = PreferenceManager.getDefaultSharedPreferences(context); + String registrationId = preferences.getString(REGISTRATION_ID_KEY, null); + + if (StringUtils.isEmpty(registrationId)) { + return registrationId; + } + + // Check if app was updated; if so, it must clear the registration ID + // since the existing regID is not guaranteed to work with the new app version. + int registeredVersion = preferences.getInt(Constants.APP_VERSION_KEY, Integer.MIN_VALUE); + int appVersion = getAppVersion(context); + if (registeredVersion != appVersion) { + return null; + } + + return registrationId; + } + + /** + * @return Application's version code from the {@code PackageManager}. + */ + private static int getAppVersion(Context context) { + try { + PackageInfo packageInfo = context.getPackageManager().getPackageInfo(context.getPackageName(), 0); + return packageInfo.versionCode; + } catch (PackageManager.NameNotFoundException e) { + Crashlytics.logException(e); + Log.e(TAG, "Could not get package name"); + return Integer.MIN_VALUE; + } + } + + /** + * Registers the application with the GCM servers asynchronously. Stores registration ID and app version code + * in shared preferences. + */ + private static void registerInBackground(final Context context) { + if (mGcm == null) { + return; + } + + new AsyncTask() { + @Override + protected Object doInBackground(Object[] params) { + try { + mRegistrationId = mGcm.register(GCM_SENDER_ID); + storeRegistrationId(context, mRegistrationId); + Log.i(TAG, "New registration ID obtained: " + mRegistrationId); + } catch (IOException e) { + e.printStackTrace(); + Crashlytics.logException(e); + + // TODO(david): Enter exponential backoff to retry register. Grr why can't Google just do this. + } + + return null; + } + }.execute(); + } + + /** + * Stores the registration ID and app versionCode in the application's {@code SharedPreferences}. + */ + private static void storeRegistrationId(Context context, String registrationId) { + SharedPreferences preferences = PreferenceManager.getDefaultSharedPreferences(context); + int appVersion = getAppVersion(context); + preferences.edit() + .putString(REGISTRATION_ID_KEY, registrationId) + .putInt(Constants.APP_VERSION_KEY, appVersion) + .commit(); + } + +} diff --git a/src/com/uwflow/flow_android/util/UserUtil.java b/src/com/uwflow/flow_android/util/UserUtil.java new file mode 100644 index 0000000..4d3f5d8 --- /dev/null +++ b/src/com/uwflow/flow_android/util/UserUtil.java @@ -0,0 +1,25 @@ +package com.uwflow.flow_android.util; + +import android.content.Context; +import android.preference.PreferenceManager; +import com.uwflow.flow_android.constant.Constants; + +public class UserUtil { + + /** + * Save the logged in user's ID in shared preferences. + */ + public static void saveLoggedInUserId(Context context, String userId) { + PreferenceManager.getDefaultSharedPreferences(context).edit() + .putString(Constants.PROFILE_ID_KEY, userId) + .commit(); + } + + /** + * Get the logged in user's ID from shared preferences. + */ + public static String getLoggedInUserId(Context context) { + return PreferenceManager.getDefaultSharedPreferences(context).getString(Constants.PROFILE_ID_KEY, null); + } + +}