package com.affectiva.affdexme; import android.app.Activity; import android.app.Fragment; import android.content.SharedPreferences; import android.content.res.Resources; import android.graphics.SurfaceTexture; import android.media.MediaPlayer; import android.net.Uri; import android.os.Build; import android.os.Bundle; import android.preference.PreferenceManager; import android.support.v4.content.ContextCompat; import android.util.Log; import android.view.LayoutInflater; import android.view.Surface; import android.view.TextureView; import android.view.View; import android.view.ViewGroup; import android.widget.Button; import android.widget.GridLayout; import android.widget.TextView; import java.util.ArrayList; import java.util.HashMap; import static com.affectiva.affdexme.MainActivity.NUM_METRICS_DISPLAYED; /** * A fragment to display a graphical menu which allows the user to select which metrics to display. */ public class MetricSelectionFragment extends Fragment implements View.OnClickListener { final static String LOG_TAG = "Affectiva"; int numberOfSelectedItems = 0; int messageAtOrUnderLimitColor; int messageOverLimitColor; SharedPreferences sharedPreferences; TextView metricChooserTextView; GridLayout gridLayout; Button clearAllButton; HashMap metricSelectors = new HashMap<>(); //An inner class object to control video playback in the metricSelectors MetricSelectionFragmentMediaPlayer fragmentMediaPlayer; @Override public View onCreateView(LayoutInflater inflater, ViewGroup container, Bundle savedInstanceState) { View fragmentLayout = inflater.inflate(R.layout.metric_chooser, container, false); initUI(fragmentLayout); fragmentMediaPlayer = new MetricSelectionFragmentMediaPlayer(); restoreSettings(savedInstanceState); //We post the methods used to populate the gridLayout view so that they run when gridLayout has been added to the layout and sized. gridLayout.post(new Runnable() { @Override public void run() { populateGrid(); updateAllGridItems(); } }); return fragmentLayout; } void initUI(View fragmentLayout) { gridLayout = (GridLayout) fragmentLayout.findViewById(R.id.metric_chooser_gridlayout); metricChooserTextView = (TextView) fragmentLayout.findViewById(R.id.metrics_chooser_textview); clearAllButton = (Button) fragmentLayout.findViewById(R.id.clear_all_button); clearAllButton.setOnClickListener( new View.OnClickListener() { @Override public void onClick(View v) { clearItems(); } } ); messageAtOrUnderLimitColor = ContextCompat.getColor(getActivity(), R.color.white); messageOverLimitColor = ContextCompat.getColor(getActivity(), R.color.red); } /** * A method to populate the metricSelectors array using information from either a saved instance bundle (if the activity is being re-created) * or sharedPreferences (if the activity is being created for the first time) */ void restoreSettings(Bundle bundle) { sharedPreferences = PreferenceManager.getDefaultSharedPreferences(getActivity()); Activity hostActivity = getActivity(); LayoutInflater inflater = hostActivity.getLayoutInflater(); Resources res = getResources(); String packageName = hostActivity.getPackageName(); //populate metricSelectors list with objects for (MetricsManager.Metrics metric : MetricsManager.getAllMetrics()) { metricSelectors.put(metric, new MetricSelector(hostActivity, inflater, res, packageName, metric)); } if (bundle != null) { //if we were passed a bundle, use its data to configure the MetricSelectors for (MetricsManager.Metrics metric : MetricsManager.getAllMetrics()) { if (bundle.getBoolean(metric.toString(), false)) { selectItem(metricSelectors.get(metric), true, false); } } } else { //otherwise, we pull the data from application preferences for (int i = 0; i < NUM_METRICS_DISPLAYED; i++) { MetricsManager.Metrics chosenMetric = PreferencesUtils.getMetricFromPrefs(sharedPreferences, i); selectItem(metricSelectors.get(chosenMetric), true, false); } } } @Override public void onSaveInstanceState(Bundle bundle) { //save whether each MetricSelector has been selected, using the metric name as the key for (MetricsManager.Metrics metric : MetricsManager.getAllMetrics()) { bundle.putBoolean(metric.toString(), metricSelectors.get(metric).getIsSelected()); } super.onSaveInstanceState(bundle); } /** * When the app is minimized, the active TextureView in fragmentMediaPlayer is destroyed, but throws an * exception if we try to remove it from its parent while in the destroyed state. Since fragmentMediaPlayer * begins assuming the TextureView is not attached to any parent, we run through all MetricSelectors and command * them to let go of the TextureView if they are the current parent. */ @Override public void onResume() { super.onResume(); for (MetricsManager.Metrics metric : MetricsManager.getAllMetrics()) { fragmentMediaPlayer.stopMetricSelectorPlayback(metricSelectors.get(metric)); } } @Override public void onPause() { super.onPause(); saveSettings(); } /* Our goal in this method is to ensure that 6 and only 6 metrics are saved in Preferences. We attempt to fill all 6 slots with metrics chosen by the user, but if the user did not choose 6 slots, we fill the remaining slots with any other metrics. */ void saveSettings() { ArrayList selectedMetrics = new ArrayList<>(); //Add all selected metrics for (MetricsManager.Metrics metric : MetricsManager.getAllMetrics()) { if (metricSelectors.get(metric).getIsSelected()) { selectedMetrics.add(metric); if (selectedMetrics.size() >= NUM_METRICS_DISPLAYED) { break; } } } //fill remaining empty slots if (selectedMetrics.size() < NUM_METRICS_DISPLAYED) { for (MetricsManager.Metrics metric : MetricsManager.getAllMetrics()) { if (!selectedMetrics.contains(metric)) { selectedMetrics.add(metric); if (selectedMetrics.size() >= NUM_METRICS_DISPLAYED) { break; } } } } //save list into application preferences SharedPreferences.Editor editor = sharedPreferences.edit(); for (int n = 0; n < selectedMetrics.size(); n++) { PreferencesUtils.saveMetricToPrefs(editor, n, selectedMetrics.get(n)); } editor.commit(); } /* While Android offers a GridView which can automatically populate a grid from an array, we wished to divide our grid items into 'Emotions' and 'Expressions' categories. Therefore, we implement a scrollable GridLayout. Furthermore, while we wished for the grid items to take up the entire grid, Android versions lower than 21 do not support the concept of weights in a GridLayout, so we manually size the grid items in this method. Note that since this method is posted as a runnable of gridLayout, it should only be run once gridLayout has been added to the layout and sized. */ void populateGrid() { LayoutInflater inflater = getActivity().getLayoutInflater(); Resources res = getResources(); int minColumnWidth = res.getDimensionPixelSize(R.dimen.metric_chooser_column_width); //calculate number of columns int gridWidth = gridLayout.getWidth(); int numColumns = gridWidth / minColumnWidth; //intentional integer division if (numColumns <= 0) { Log.e(LOG_TAG, "Desired Column Width too large! Unable to populate Grid"); return; } int columnWidth = (int) ((float) gridWidth / (float) numColumns); //This integer reference will be used across methods to keep track of how many rows we have created. //Each method we pass it into leaves it at a value indicating the next row number that views should be added to. IntRef currentRow = new IntRef(); addHeader("Emotions", currentRow, numColumns, inflater); addGridItems(currentRow, numColumns, inflater, res, columnWidth, MetricsManager.Emotions.values()); addHeader("Expressions", currentRow, numColumns, inflater); addGridItems(currentRow, numColumns, inflater, res, columnWidth, MetricsManager.Expressions.values()); // If you wanted to add Emoji as selectable metrics, you would uncomment the two lines below // addHeader("Emoji", currentRow, numColumns, inflater); // addGridItems(currentRow, numColumns, inflater, res, columnWidth, MetricsManager.Emojis.values()); gridLayout.setColumnCount(numColumns); gridLayout.setRowCount(currentRow.value); } //adds a header (consisting of a TextView and border line) to the grid void addHeader(String name, IntRef currentRow, int numColumns, LayoutInflater inflater) { View header = inflater.inflate(R.layout.grid_header, null); //each header should take up one row and all available columns GridLayout.LayoutParams params = new GridLayout.LayoutParams(GridLayout.spec(currentRow.value, 1), GridLayout.spec(0, numColumns)); params.width = gridLayout.getWidth(); header.setLayoutParams(params); gridLayout.addView(header); ((TextView) header.findViewById(R.id.header_text)).setText(name); currentRow.value += 1; //point currentRow to row where next views should be added } //adds a set of metrics (using the data from the MetricsManager object from index 'start' to index 'end') void addGridItems(IntRef currentRow, int numColumns, LayoutInflater inflater, Resources res, int size, MetricsManager.Metrics[] metricsToAdd) { //keeps track of the column we are adding to int col = -1; //start col at -1 so it becomes 0 during first iteration of for loop for (MetricsManager.Metrics metric : metricsToAdd) { col += 1; if (col >= numColumns) { col = 0; currentRow.value += 1; } MetricSelector item = metricSelectors.get(metric); if (item != null) { GridLayout.LayoutParams params = new GridLayout.LayoutParams(); params.width = size; params.height = size; params.columnSpec = GridLayout.spec(col); params.rowSpec = GridLayout.spec(currentRow.value); item.setLayoutParams(params); item.setOnClickListener(this); gridLayout.addView(item); } else { Log.e(this.getClass().getSimpleName(), "Unknown MetricSelector item for Metric: " + metric.toString()); } } currentRow.value += 1; //point currentRow to row where next views should be added } @Override public void onClick(View v) { MetricSelector item = (MetricSelector) v; selectItem(item, !item.getIsSelected(), true); //select item if de-selected, and vice-versa updateAllGridItems(); //each click will result in all items being updated } /* Updates numberOfSelectedItems as well as the message presented by the text at the top of the activity */ void selectItem(MetricSelector metricSelector, boolean isSelected, boolean playVideo) { //update numberOfSelectedItems boolean wasSelected = metricSelector.getIsSelected(); if (!wasSelected && isSelected) { numberOfSelectedItems += 1; if (playVideo) { fragmentMediaPlayer.startMetricSelectorPlayback(metricSelector); } } else if (wasSelected && !isSelected) { numberOfSelectedItems -= 1; if (playVideo) { fragmentMediaPlayer.stopMetricSelectorPlayback(metricSelector); } } metricSelector.setIsSelected(isSelected); if (numberOfSelectedItems == 1) { metricChooserTextView.setText("1 metric chosen."); } else { metricChooserTextView.setText(String.format("%d metrics chosen.", numberOfSelectedItems)); } if (numberOfSelectedItems <= NUM_METRICS_DISPLAYED) { metricChooserTextView.setTextColor(messageAtOrUnderLimitColor); } else { metricChooserTextView.setTextColor(messageOverLimitColor); } } void clearItems() { for (MetricsManager.Metrics metric : MetricsManager.getAllMetrics()) { selectItem(metricSelectors.get(metric), false, true); } updateAllGridItems(); } //loop through all grid items, and update those which are tagged with an Integer (those which represent selectable metrics). void updateAllGridItems() { for (MetricsManager.Metrics metric : MetricsManager.getAllMetrics()) { metricSelectors.get(metric).setUnderOrOverLimit(numberOfSelectedItems <= NUM_METRICS_DISPLAYED); } } @Override public void onDestroy() { super.onDestroy(); fragmentMediaPlayer.destroy(); } /** * These are not all the MediaPlayer states defined by Android, but they are all the ones we are interested in. * Note that SafeMediaPlayer never stays in the STOPPED state, so we don't include it. */ enum MediaPlayerState { IDLE, INIT, PREPARED, PLAYING } interface OnSafeMediaPlayerPreparedListener { void onSafeMediaPlayerPrepared(); } //IntRef represents a reference to a mutable integer value //It is used to keep track of how many rows have been created in the populateGrid() method class IntRef { public int value; public IntRef() { value = 0; } } /** * The MetricSelector objects in this fragment will play a video when selected. To keep memory usage low, we use only one MediaPlayer * object to control video playback. Video is rendered on a single TextureView. * Chain of events that lead to video playback: * -When a MetricSelector is clicked, the MediaPlayer.setDataSource() is called to set the video file * -The TextureView is added to the view hierarchy of the MetricSelector, causing the onSurfaceTextureAvailable callback to fire * -The TextureView is bound to the MediaPlayer through MediaPlayer.setSurface(), then MediaPlayer.prepareAsync() is called * -Once preparation is complete, MediaPlayer.start() is called * -MediaPlayer.stop() will be called when playback finishes or the item has been de-selected, at which point the TextureView will * be removed from the MetricSelector's view hierarchy, causing onSurfaceTextureDestroyed(), where we call MediaPlayer.setSurface(null) */ class MetricSelectionFragmentMediaPlayer { SafeMediaPlayer safePlayer; TextureView textureView; MetricSelector videoPlayingSelector; public MetricSelectionFragmentMediaPlayer() { safePlayer = new SafeMediaPlayer(getActivity()); safePlayer.setOnPreparedListener(new OnSafeMediaPlayerPreparedListener() { @Override public void onSafeMediaPlayerPrepared() { safePlayer.start(); if (!greaterThanHoneyComb()) { safePlayer.seekTo(1); } } }); /** * Although it is best to remove the MetricSelector video cover upon reception of the * VIDEO_RENDERING event, this event is only available on SDK 17 and above. */ if (greaterThanHoneyComb()) { safePlayer.setOnInfoListener(new MediaPlayer.OnInfoListener() { @Override public boolean onInfo(MediaPlayer mp, int what, int extra) { if (what == MediaPlayer.MEDIA_INFO_VIDEO_RENDERING_START) { videoPlayingSelector.removeCover(); } return false; } }); } else { safePlayer.setOnSeekListener(new MediaPlayer.OnSeekCompleteListener() { @Override public void onSeekComplete(MediaPlayer mp) { videoPlayingSelector.removeCover(); } }); } safePlayer.setOnCompletionListener(new MediaPlayer.OnCompletionListener() { @Override public void onCompletion(MediaPlayer mp) { Uri nextVideoUri = videoPlayingSelector.getNextVideoResourceURI(); if (nextVideoUri == null) { endVideoPlayback(); } else { safePlayer.stopAndReset(); safePlayer.setDataSource(nextVideoUri); safePlayer.prepareAsync(); } } }); textureView = new TextureView(getActivity()); textureView.setVisibility(View.GONE); textureView.setLayoutParams(new ViewGroup.LayoutParams(ViewGroup.LayoutParams.WRAP_CONTENT, ViewGroup.LayoutParams.WRAP_CONTENT)); textureView.setSurfaceTextureListener(new TextureView.SurfaceTextureListener() { @Override public void onSurfaceTextureAvailable(SurfaceTexture surface, int width, int height) { safePlayer.setSurface(new Surface(surface)); safePlayer.prepareAsync(); } @Override public void onSurfaceTextureSizeChanged(SurfaceTexture surface, int width, int height) { } @Override public boolean onSurfaceTextureDestroyed(SurfaceTexture surface) { safePlayer.stopAndReset(); safePlayer.setSurface(null); return false; } @Override public void onSurfaceTextureUpdated(SurfaceTexture surface) { } }); } private void startVideoPlayback(MetricSelector metricSelector) { videoPlayingSelector = metricSelector; videoPlayingSelector.initIndex(); Uri videoUri = metricSelector.getNextVideoResourceURI(); if (videoUri != null) { safePlayer.setDataSource(videoUri); metricSelector.displayVideo(textureView); //will cause onSurfaceTextureAvailable to fire } } private void endVideoPlayback() { videoPlayingSelector.displayCover(); safePlayer.stopAndReset(); videoPlayingSelector.removeVideo(); //will cause onSurfaceTextureDestroyed() to fire } void startMetricSelectorPlayback(MetricSelector metricSelector) { if (videoPlayingSelector != null) { endVideoPlayback(); //stop previous video } startVideoPlayback(metricSelector); } void stopMetricSelectorPlayback(MetricSelector metricSelector) { if (metricSelector == videoPlayingSelector) { //if de-selected item is a playing video, stop it endVideoPlayback(); } } public void destroy() { safePlayer.release(); //release resources of media player textureView = null; } boolean greaterThanHoneyComb() { return Build.VERSION.SDK_INT >= 17; } } /** * A Facade to ensure our MediaPlayer does not throw an error due to an invalid state change. */ class SafeMediaPlayer { MediaPlayerState state; MediaPlayer mediaPlayer; Activity activity; OnSafeMediaPlayerPreparedListener listener = null; public SafeMediaPlayer(Activity activity) { mediaPlayer = new MediaPlayer(); state = MediaPlayerState.IDLE; this.activity = activity; mediaPlayer.setOnPreparedListener(new MediaPlayer.OnPreparedListener() { @Override public void onPrepared(MediaPlayer mp) { state = MediaPlayerState.PREPARED; if (listener != null) { listener.onSafeMediaPlayerPrepared(); } } }); } void setDataSource(Uri source) { if (state == MediaPlayerState.IDLE) { try { mediaPlayer.setDataSource(activity, source); } catch (Exception e) { Log.e(LOG_TAG, e.getMessage()); return; //If unable to setup video, exit function } state = MediaPlayerState.INIT; } } void prepareAsync() { if (state == MediaPlayerState.INIT) { mediaPlayer.prepareAsync(); } } void start() { if (state == MediaPlayerState.PREPARED) { mediaPlayer.start(); state = MediaPlayerState.PLAYING; } } void seekTo(int msec) { if (state == MediaPlayerState.PREPARED || state == MediaPlayerState.PLAYING) { mediaPlayer.seekTo(msec); } } void stopAndReset() { if (state == MediaPlayerState.PLAYING || state == MediaPlayerState.PREPARED) { mediaPlayer.stop(); } mediaPlayer.reset(); //can be called from any state state = MediaPlayerState.IDLE; } void setOnPreparedListener(OnSafeMediaPlayerPreparedListener listener) { this.listener = listener; } //The rest of the methods are delegation methods void setSurface(Surface surface) { mediaPlayer.setSurface(surface); } void setOnCompletionListener(MediaPlayer.OnCompletionListener listener) { mediaPlayer.setOnCompletionListener(listener); } void setOnSeekListener(MediaPlayer.OnSeekCompleteListener listener) { mediaPlayer.setOnSeekCompleteListener(listener); } void setOnInfoListener(MediaPlayer.OnInfoListener listener) { mediaPlayer.setOnInfoListener(listener); } void release() { mediaPlayer.release(); mediaPlayer = null; } } }