diff --git a/build/sdk.atree b/build/sdk.atree index baca11f1d..98bd984eb 100644 --- a/build/sdk.atree +++ b/build/sdk.atree @@ -330,6 +330,10 @@ developers/build/prebuilts/gradle/RuntimePermissionsBasic sam developers/build/prebuilts/gradle/ActiveNotifications samples/${PLATFORM_NAME}/notification/ActiveNotifications developers/build/prebuilts/gradle/Camera2Raw samples/${PLATFORM_NAME}/media/Camera2Raw developers/build/prebuilts/gradle/AutoBackupForApps samples/${PLATFORM_NAME}/content/AutoBackupForApps +developers/build/prebuilts/gradle/DirectShare samples/${PLATFORM_NAME}/content/DirectShare +developers/build/prebuilts/gradle/MidiScope samples/${PLATFORM_NAME}/media/MidiScope +developers/build/prebuilts/gradle/MidiSynth samples/${PLATFORM_NAME}/media/MidiSynth +developers/build/prebuilts/gradle/AsymmetricFingerprintDialog samples/${PLATFORM_NAME}/security/AsymmetricFingerprintDialog developers/build/prebuilts/androidtv samples/${PLATFORM_NAME}/androidtv @@ -346,12 +350,14 @@ developers/build/prebuilts/gradle/JumpingJack samples/${PLATFO developers/build/prebuilts/gradle/Notifications samples/${PLATFORM_NAME}/wearable/Notifications developers/build/prebuilts/gradle/Quiz samples/${PLATFORM_NAME}/wearable/Quiz developers/build/prebuilts/gradle/RecipeAssistant samples/${PLATFORM_NAME}/wearable/RecipeAssistant +developers/build/prebuilts/gradle/RuntimePermissionsWear samples/${PLATFORM_NAME}/wearable/RuntimePermissionsWear developers/build/prebuilts/gradle/SkeletonWearableApp samples/${PLATFORM_NAME}/wearable/SkeletonWearableApp developers/build/prebuilts/gradle/SpeedTracker samples/${PLATFORM_NAME}/wearable/SpeedTracker developers/build/prebuilts/gradle/SynchronizedNotifications samples/${PLATFORM_NAME}/wearable/SynchronizedNotifications developers/build/prebuilts/gradle/Timer samples/${PLATFORM_NAME}/wearable/Timer developers/build/prebuilts/gradle/WatchFace samples/${PLATFORM_NAME}/wearable/WatchFace developers/build/prebuilts/gradle/WatchViewStub samples/${PLATFORM_NAME}/wearable/WatchViewStub +developers/build/prebuilts/gradle/WearSpeakerSample samples/${PLATFORM_NAME}/wearable/WearSpeakerSample developers/build/prebuilts/gradle/XYZTouristAttractions samples/${PLATFORM_NAME}/wearable/XYZTouristAttractions # Old sample tree diff --git a/samples/browseable/ActionBarCompat-Basic/res/values-v11/template-styles.xml b/samples/browseable/ActionBarCompat-Basic/res/values-v11/template-styles.xml index f261bb5c2..8c1ea66f2 100644 --- a/samples/browseable/ActionBarCompat-Basic/res/values-v11/template-styles.xml +++ b/samples/browseable/ActionBarCompat-Basic/res/values-v11/template-styles.xml @@ -17,6 +17,6 @@ - diff --git a/samples/browseable/ActionBarCompat-Basic/res/values/template-styles.xml b/samples/browseable/ActionBarCompat-Basic/res/values/template-styles.xml index 74086d293..6e7d593dd 100644 --- a/samples/browseable/ActionBarCompat-Basic/res/values/template-styles.xml +++ b/samples/browseable/ActionBarCompat-Basic/res/values/template-styles.xml @@ -18,7 +18,7 @@ - diff --git a/samples/browseable/ActionBarCompat-ListPopupMenu/res/values/template-styles.xml b/samples/browseable/ActionBarCompat-ListPopupMenu/res/values/template-styles.xml index 74086d293..6e7d593dd 100644 --- a/samples/browseable/ActionBarCompat-ListPopupMenu/res/values/template-styles.xml +++ b/samples/browseable/ActionBarCompat-ListPopupMenu/res/values/template-styles.xml @@ -18,7 +18,7 @@ - diff --git a/samples/browseable/ActionBarCompat-ShareActionProvider/res/values/template-styles.xml b/samples/browseable/ActionBarCompat-ShareActionProvider/res/values/template-styles.xml index 74086d293..6e7d593dd 100644 --- a/samples/browseable/ActionBarCompat-ShareActionProvider/res/values/template-styles.xml +++ b/samples/browseable/ActionBarCompat-ShareActionProvider/res/values/template-styles.xml @@ -18,7 +18,7 @@ - diff --git a/samples/browseable/ActionBarCompat-Styled/res/values/template-styles.xml b/samples/browseable/ActionBarCompat-Styled/res/values/template-styles.xml index 74086d293..6e7d593dd 100644 --- a/samples/browseable/ActionBarCompat-Styled/res/values/template-styles.xml +++ b/samples/browseable/ActionBarCompat-Styled/res/values/template-styles.xml @@ -18,7 +18,7 @@ - diff --git a/samples/browseable/ActiveNotifications/res/values/template-styles.xml b/samples/browseable/ActiveNotifications/res/values/template-styles.xml index 74086d293..6e7d593dd 100644 --- a/samples/browseable/ActiveNotifications/res/values/template-styles.xml +++ b/samples/browseable/ActiveNotifications/res/values/template-styles.xml @@ -18,7 +18,7 @@ - diff --git a/samples/browseable/ActivityInstrumentation/res/values/template-styles.xml b/samples/browseable/ActivityInstrumentation/res/values/template-styles.xml index 74086d293..6e7d593dd 100644 --- a/samples/browseable/ActivityInstrumentation/res/values/template-styles.xml +++ b/samples/browseable/ActivityInstrumentation/res/values/template-styles.xml @@ -18,7 +18,7 @@ - diff --git a/samples/browseable/ActivitySceneTransitionBasic/res/values/template-styles.xml b/samples/browseable/ActivitySceneTransitionBasic/res/values/template-styles.xml index 74086d293..6e7d593dd 100644 --- a/samples/browseable/ActivitySceneTransitionBasic/res/values/template-styles.xml +++ b/samples/browseable/ActivitySceneTransitionBasic/res/values/template-styles.xml @@ -18,7 +18,7 @@ - diff --git a/samples/browseable/AdvancedImmersiveMode/res/values/template-styles.xml b/samples/browseable/AdvancedImmersiveMode/res/values/template-styles.xml index 74086d293..6e7d593dd 100644 --- a/samples/browseable/AdvancedImmersiveMode/res/values/template-styles.xml +++ b/samples/browseable/AdvancedImmersiveMode/res/values/template-styles.xml @@ -18,7 +18,7 @@ - diff --git a/samples/browseable/AgendaData/Application/res/values/base-strings.xml b/samples/browseable/AgendaData/Application/res/values/base-strings.xml index acc40cf94..3f806d0a4 100644 --- a/samples/browseable/AgendaData/Application/res/values/base-strings.xml +++ b/samples/browseable/AgendaData/Application/res/values/base-strings.xml @@ -22,10 +22,10 @@ Syncs calendar events to your wearable at the press of a button, using the Wearable - DataApi to transmit data such as event time, description, and background image. The DataItems can be - deleted individually via an action on the event notifications, or all at once via a button on the - companion. When deleted using the notification action, a ConfirmationActivity is used to indicate - success or failure. + DataApi to transmit data such as event time, description, and background image. The + DataItems can be deleted individually via an action on the event notifications, or all + at once via a button on the companion. When deleted using the notification action, a + ConfirmationActivity is used to indicate success or failure. ]]> diff --git a/samples/browseable/AgendaData/Application/res/values/strings.xml b/samples/browseable/AgendaData/Application/res/values/strings.xml index 9969f4f24..84cb60dba 100644 --- a/samples/browseable/AgendaData/Application/res/values/strings.xml +++ b/samples/browseable/AgendaData/Application/res/values/strings.xml @@ -17,5 +17,9 @@ Sync calendar events to wearable Delete calendar events from wearable - Log + Deletion Log: + Permissions granted. Send Calendar events to Wear device. + Permission requests were denied. Can\'t send calendar events. + + OK diff --git a/samples/browseable/AgendaData/Application/res/values/template-styles.xml b/samples/browseable/AgendaData/Application/res/values/template-styles.xml index 74086d293..6e7d593dd 100644 --- a/samples/browseable/AgendaData/Application/res/values/template-styles.xml +++ b/samples/browseable/AgendaData/Application/res/values/template-styles.xml @@ -18,7 +18,7 @@ - diff --git a/samples/browseable/AppRestrictionEnforcer/res/values/strings.xml b/samples/browseable/AppRestrictionEnforcer/res/values/strings.xml index e35daee3a..ead415275 100644 --- a/samples/browseable/AppRestrictionEnforcer/res/values/strings.xml +++ b/samples/browseable/AppRestrictionEnforcer/res/values/strings.xml @@ -29,4 +29,14 @@ Number: Rank: Approvals: + Profile: + Name + Age + Items: + Add + Key + Value + Remove + %1$s: %2$s + Add a new item diff --git a/samples/browseable/AppRestrictionEnforcer/res/values/template-styles.xml b/samples/browseable/AppRestrictionEnforcer/res/values/template-styles.xml index 74086d293..6e7d593dd 100644 --- a/samples/browseable/AppRestrictionEnforcer/res/values/template-styles.xml +++ b/samples/browseable/AppRestrictionEnforcer/res/values/template-styles.xml @@ -18,7 +18,7 @@ - diff --git a/samples/browseable/AppRestrictionSchema/res/values/restriction_values.xml b/samples/browseable/AppRestrictionSchema/res/values/restriction_values.xml index 558d097ff..53be74669 100644 --- a/samples/browseable/AppRestrictionSchema/res/values/restriction_values.xml +++ b/samples/browseable/AppRestrictionSchema/res/values/restriction_values.xml @@ -74,4 +74,23 @@ limitations under the License. This restriction is hidden and will not be shown to the administrator. (Hidden restriction must have some default value) + + Sample profile + Profile + + John + The name of this person + Name + + 25 + The age of this person + Age + + + Sample items + Items + Item + Key + Value + diff --git a/samples/browseable/AppRestrictionSchema/res/values/strings.xml b/samples/browseable/AppRestrictionSchema/res/values/strings.xml index 6dce123f5..1ec68d54c 100644 --- a/samples/browseable/AppRestrictionSchema/res/values/strings.xml +++ b/samples/browseable/AppRestrictionSchema/res/values/strings.xml @@ -25,5 +25,7 @@ limitations under the License. Your rank: %s Approvals you have: %s none + Your profile: %1$s (%2$d) + Your items: %s diff --git a/samples/browseable/AppRestrictionSchema/res/values/template-styles.xml b/samples/browseable/AppRestrictionSchema/res/values/template-styles.xml index 74086d293..6e7d593dd 100644 --- a/samples/browseable/AppRestrictionSchema/res/values/template-styles.xml +++ b/samples/browseable/AppRestrictionSchema/res/values/template-styles.xml @@ -18,7 +18,7 @@ - diff --git a/samples/browseable/AppRestrictions/res/values/template-styles.xml b/samples/browseable/AppRestrictions/res/values/template-styles.xml index 74086d293..6e7d593dd 100644 --- a/samples/browseable/AppRestrictions/res/values/template-styles.xml +++ b/samples/browseable/AppRestrictions/res/values/template-styles.xml @@ -18,7 +18,7 @@ - diff --git a/samples/browseable/AppUsageStatistics/res/values/template-styles.xml b/samples/browseable/AppUsageStatistics/res/values/template-styles.xml index 74086d293..6e7d593dd 100644 --- a/samples/browseable/AppUsageStatistics/res/values/template-styles.xml +++ b/samples/browseable/AppUsageStatistics/res/values/template-styles.xml @@ -18,7 +18,7 @@ - + + diff --git a/samples/browseable/AsymmetricFingerprintDialog/res/values-v11/template-styles.xml b/samples/browseable/AsymmetricFingerprintDialog/res/values-v11/template-styles.xml new file mode 100644 index 000000000..8c1ea66f2 --- /dev/null +++ b/samples/browseable/AsymmetricFingerprintDialog/res/values-v11/template-styles.xml @@ -0,0 +1,22 @@ + + + + + + + + diff --git a/samples/browseable/AsymmetricFingerprintDialog/res/values/base-strings.xml b/samples/browseable/AsymmetricFingerprintDialog/res/values/base-strings.xml new file mode 100644 index 000000000..72dabc1d2 --- /dev/null +++ b/samples/browseable/AsymmetricFingerprintDialog/res/values/base-strings.xml @@ -0,0 +1,30 @@ + + + + + AsymmetricFingerprintDialog + + + + diff --git a/samples/browseable/AsymmetricFingerprintDialog/res/values/colors.xml b/samples/browseable/AsymmetricFingerprintDialog/res/values/colors.xml new file mode 100644 index 000000000..a24f3c8fc --- /dev/null +++ b/samples/browseable/AsymmetricFingerprintDialog/res/values/colors.xml @@ -0,0 +1,21 @@ + + + + #f4511e + #42000000 + #009688 + diff --git a/samples/browseable/AsymmetricFingerprintDialog/res/values/strings.xml b/samples/browseable/AsymmetricFingerprintDialog/res/values/strings.xml new file mode 100644 index 000000000..f44c06d68 --- /dev/null +++ b/samples/browseable/AsymmetricFingerprintDialog/res/values/strings.xml @@ -0,0 +1,39 @@ + + + + Settings + Cancel + Use password + Sign in + Ok + Password + Confirm fingerprint to continue + Touch sensor + Enter your store password to continue + Purchase + Fingerprint not recognized. Try again + Fingerprint recognized + White Mesh Pluto Backpack + $62.68 + Mesh backpack in white. Black textile trim throughout. + Purchase successful + Purchase failed + A new fingerprint was added to this device, so your password is required. + Use fingerprint in the future + Use fingerprint to authenticate + use_fingerprint_to_authenticate_key + diff --git a/samples/browseable/AsymmetricFingerprintDialog/res/values/template-dimens.xml b/samples/browseable/AsymmetricFingerprintDialog/res/values/template-dimens.xml new file mode 100644 index 000000000..39e710b5c --- /dev/null +++ b/samples/browseable/AsymmetricFingerprintDialog/res/values/template-dimens.xml @@ -0,0 +1,32 @@ + + + + + + + 4dp + 8dp + 16dp + 32dp + 64dp + + + + @dimen/margin_medium + @dimen/margin_medium + + diff --git a/samples/browseable/AsymmetricFingerprintDialog/res/values/template-styles.xml b/samples/browseable/AsymmetricFingerprintDialog/res/values/template-styles.xml new file mode 100644 index 000000000..6e7d593dd --- /dev/null +++ b/samples/browseable/AsymmetricFingerprintDialog/res/values/template-styles.xml @@ -0,0 +1,42 @@ + + + + + + + + + + + diff --git a/samples/browseable/AsymmetricFingerprintDialog/res/xml/preferences.xml b/samples/browseable/AsymmetricFingerprintDialog/res/xml/preferences.xml new file mode 100644 index 000000000..761391d5a --- /dev/null +++ b/samples/browseable/AsymmetricFingerprintDialog/res/xml/preferences.xml @@ -0,0 +1,23 @@ + + + + + \ No newline at end of file diff --git a/samples/browseable/AsymmetricFingerprintDialog/src/com.example.android.asymmetricfingerprintdialog/FingerprintAuthenticationDialogFragment.java b/samples/browseable/AsymmetricFingerprintDialog/src/com.example.android.asymmetricfingerprintdialog/FingerprintAuthenticationDialogFragment.java new file mode 100644 index 000000000..a56556f13 --- /dev/null +++ b/samples/browseable/AsymmetricFingerprintDialog/src/com.example.android.asymmetricfingerprintdialog/FingerprintAuthenticationDialogFragment.java @@ -0,0 +1,320 @@ +/* + * Copyright (C) 2015 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License + */ + +package com.example.android.asymmetricfingerprintdialog; + +import com.example.android.asymmetricfingerprintdialog.server.StoreBackend; +import com.example.android.asymmetricfingerprintdialog.server.Transaction; + +import android.app.Activity; +import android.app.DialogFragment; +import android.content.SharedPreferences; +import android.hardware.fingerprint.FingerprintManager; +import android.os.Bundle; +import android.view.KeyEvent; +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; +import android.view.inputmethod.EditorInfo; +import android.view.inputmethod.InputMethodManager; +import android.widget.Button; +import android.widget.CheckBox; +import android.widget.EditText; +import android.widget.ImageView; +import android.widget.TextView; + +import java.io.IOException; +import java.security.KeyFactory; +import java.security.KeyStore; +import java.security.KeyStoreException; +import java.security.NoSuchAlgorithmException; +import java.security.PublicKey; +import java.security.SecureRandom; +import java.security.Signature; +import java.security.SignatureException; +import java.security.cert.CertificateException; +import java.security.spec.InvalidKeySpecException; +import java.security.spec.X509EncodedKeySpec; + +import javax.inject.Inject; + +/** + * A dialog which uses fingerprint APIs to authenticate the user, and falls back to password + * authentication if fingerprint is not available. + */ +public class FingerprintAuthenticationDialogFragment extends DialogFragment + implements TextView.OnEditorActionListener, FingerprintUiHelper.Callback { + + private Button mCancelButton; + private Button mSecondDialogButton; + private View mFingerprintContent; + private View mBackupContent; + private EditText mPassword; + private CheckBox mUseFingerprintFutureCheckBox; + private TextView mPasswordDescriptionTextView; + private TextView mNewFingerprintEnrolledTextView; + + private Stage mStage = Stage.FINGERPRINT; + + private FingerprintManager.CryptoObject mCryptoObject; + private FingerprintUiHelper mFingerprintUiHelper; + private MainActivity mActivity; + + @Inject FingerprintUiHelper.FingerprintUiHelperBuilder mFingerprintUiHelperBuilder; + @Inject InputMethodManager mInputMethodManager; + @Inject SharedPreferences mSharedPreferences; + @Inject StoreBackend mStoreBackend; + + @Inject + public FingerprintAuthenticationDialogFragment() {} + + @Override + public void onCreate(Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + + // Do not create a new Fragment when the Activity is re-created such as orientation changes. + setRetainInstance(true); + setStyle(DialogFragment.STYLE_NORMAL, android.R.style.Theme_Material_Light_Dialog); + + // We register a new user account here. Real apps should do this with proper UIs. + enroll(); + } + + @Override + public View onCreateView(LayoutInflater inflater, ViewGroup container, + Bundle savedInstanceState) { + getDialog().setTitle(getString(R.string.sign_in)); + View v = inflater.inflate(R.layout.fingerprint_dialog_container, container, false); + mCancelButton = (Button) v.findViewById(R.id.cancel_button); + mCancelButton.setOnClickListener(new View.OnClickListener() { + @Override + public void onClick(View view) { + dismiss(); + } + }); + + mSecondDialogButton = (Button) v.findViewById(R.id.second_dialog_button); + mSecondDialogButton.setOnClickListener(new View.OnClickListener() { + @Override + public void onClick(View view) { + if (mStage == Stage.FINGERPRINT) { + goToBackup(); + } else { + verifyPassword(); + } + } + }); + mFingerprintContent = v.findViewById(R.id.fingerprint_container); + mBackupContent = v.findViewById(R.id.backup_container); + mPassword = (EditText) v.findViewById(R.id.password); + mPassword.setOnEditorActionListener(this); + mPasswordDescriptionTextView = (TextView) v.findViewById(R.id.password_description); + mUseFingerprintFutureCheckBox = (CheckBox) + v.findViewById(R.id.use_fingerprint_in_future_check); + mNewFingerprintEnrolledTextView = (TextView) + v.findViewById(R.id.new_fingerprint_enrolled_description); + mFingerprintUiHelper = mFingerprintUiHelperBuilder.build( + (ImageView) v.findViewById(R.id.fingerprint_icon), + (TextView) v.findViewById(R.id.fingerprint_status), this); + updateStage(); + + // If fingerprint authentication is not available, switch immediately to the backup + // (password) screen. + if (!mFingerprintUiHelper.isFingerprintAuthAvailable()) { + goToBackup(); + } + return v; + } + + @Override + public void onResume() { + super.onResume(); + if (mStage == Stage.FINGERPRINT) { + mFingerprintUiHelper.startListening(mCryptoObject); + } + } + + public void setStage(Stage stage) { + mStage = stage; + } + + @Override + public void onPause() { + super.onPause(); + mFingerprintUiHelper.stopListening(); + } + + @Override + public void onAttach(Activity activity) { + super.onAttach(activity); + mActivity = (MainActivity) activity; + } + + /** + * Sets the crypto object to be passed in when authenticating with fingerprint. + */ + public void setCryptoObject(FingerprintManager.CryptoObject cryptoObject) { + mCryptoObject = cryptoObject; + } + + /** + * Switches to backup (password) screen. This either can happen when fingerprint is not + * available or the user chooses to use the password authentication method by pressing the + * button. This can also happen when the user had too many fingerprint attempts. + */ + private void goToBackup() { + mStage = Stage.PASSWORD; + updateStage(); + mPassword.requestFocus(); + + // Show the keyboard. + mPassword.postDelayed(mShowKeyboardRunnable, 500); + + // Fingerprint is not used anymore. Stop listening for it. + mFingerprintUiHelper.stopListening(); + } + + /** + * Enrolls a user to the fake backend. + */ + private void enroll() { + try { + KeyStore keyStore = KeyStore.getInstance("AndroidKeyStore"); + keyStore.load(null); + PublicKey publicKey = keyStore.getCertificate(MainActivity.KEY_NAME).getPublicKey(); + // Provide the public key to the backend. In most cases, the key needs to be transmitted + // to the backend over the network, for which Key.getEncoded provides a suitable wire + // format (X.509 DER-encoded). The backend can then create a PublicKey instance from the + // X.509 encoded form using KeyFactory.generatePublic. This conversion is also currently + // needed on API Level 23 (Android M) due to a platform bug which prevents the use of + // Android Keystore public keys when their private keys require user authentication. + // This conversion creates a new public key which is not backed by Android Keystore and + // thus is not affected by the bug. + KeyFactory factory = KeyFactory.getInstance(publicKey.getAlgorithm()); + X509EncodedKeySpec spec = new X509EncodedKeySpec(publicKey.getEncoded()); + PublicKey verificationKey = factory.generatePublic(spec); + mStoreBackend.enroll("user", "password", verificationKey); + } catch (KeyStoreException | CertificateException | NoSuchAlgorithmException | + IOException | InvalidKeySpecException e) { + e.printStackTrace(); + } + } + + /** + * Checks whether the current entered password is correct, and dismisses the the dialog and lets + * the activity know about the result. + */ + private void verifyPassword() { + Transaction transaction = new Transaction("user", 1, new SecureRandom().nextLong()); + if (!mStoreBackend.verify(transaction, mPassword.getText().toString())) { + return; + } + if (mStage == Stage.NEW_FINGERPRINT_ENROLLED) { + SharedPreferences.Editor editor = mSharedPreferences.edit(); + editor.putBoolean(getString(R.string.use_fingerprint_to_authenticate_key), + mUseFingerprintFutureCheckBox.isChecked()); + editor.apply(); + + if (mUseFingerprintFutureCheckBox.isChecked()) { + // Re-create the key so that fingerprints including new ones are validated. + mActivity.createKeyPair(); + mStage = Stage.FINGERPRINT; + } + } + mPassword.setText(""); + mActivity.onPurchased(null); + dismiss(); + } + + private final Runnable mShowKeyboardRunnable = new Runnable() { + @Override + public void run() { + mInputMethodManager.showSoftInput(mPassword, 0); + } + }; + + private void updateStage() { + switch (mStage) { + case FINGERPRINT: + mCancelButton.setText(R.string.cancel); + mSecondDialogButton.setText(R.string.use_password); + mFingerprintContent.setVisibility(View.VISIBLE); + mBackupContent.setVisibility(View.GONE); + break; + case NEW_FINGERPRINT_ENROLLED: + // Intentional fall through + case PASSWORD: + mCancelButton.setText(R.string.cancel); + mSecondDialogButton.setText(R.string.ok); + mFingerprintContent.setVisibility(View.GONE); + mBackupContent.setVisibility(View.VISIBLE); + if (mStage == Stage.NEW_FINGERPRINT_ENROLLED) { + mPasswordDescriptionTextView.setVisibility(View.GONE); + mNewFingerprintEnrolledTextView.setVisibility(View.VISIBLE); + mUseFingerprintFutureCheckBox.setVisibility(View.VISIBLE); + } + break; + } + } + + @Override + public boolean onEditorAction(TextView v, int actionId, KeyEvent event) { + if (actionId == EditorInfo.IME_ACTION_GO) { + verifyPassword(); + return true; + } + return false; + } + + @Override + public void onAuthenticated() { + // Callback from FingerprintUiHelper. Let the activity know that authentication was + // successful. + mPassword.setText(""); + Signature signature = mCryptoObject.getSignature(); + // Include a client nonce in the transaction so that the nonce is also signed by the private + // key and the backend can verify that the same nonce can't be used to prevent replay + // attacks. + Transaction transaction = new Transaction("user", 1, new SecureRandom().nextLong()); + try { + signature.update(transaction.toByteArray()); + byte[] sigBytes = signature.sign(); + if (mStoreBackend.verify(transaction, sigBytes)) { + mActivity.onPurchased(sigBytes); + dismiss(); + } else { + mActivity.onPurchaseFailed(); + dismiss(); + } + } catch (SignatureException e) { + throw new RuntimeException(e); + } + } + + @Override + public void onError() { + goToBackup(); + } + + /** + * Enumeration to indicate which authentication method the user is trying to authenticate with. + */ + public enum Stage { + FINGERPRINT, + NEW_FINGERPRINT_ENROLLED, + PASSWORD + } +} diff --git a/samples/browseable/AsymmetricFingerprintDialog/src/com.example.android.asymmetricfingerprintdialog/FingerprintModule.java b/samples/browseable/AsymmetricFingerprintDialog/src/com.example.android.asymmetricfingerprintdialog/FingerprintModule.java new file mode 100644 index 000000000..ae3acf801 --- /dev/null +++ b/samples/browseable/AsymmetricFingerprintDialog/src/com.example.android.asymmetricfingerprintdialog/FingerprintModule.java @@ -0,0 +1,111 @@ +/* + * Copyright (C) 2015 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License + */ + +package com.example.android.asymmetricfingerprintdialog; + +import com.example.android.asymmetricfingerprintdialog.server.StoreBackend; +import com.example.android.asymmetricfingerprintdialog.server.StoreBackendImpl; + +import android.app.KeyguardManager; +import android.content.Context; +import android.content.SharedPreferences; +import android.hardware.fingerprint.FingerprintManager; +import android.preference.PreferenceManager; +import android.security.keystore.KeyProperties; +import android.view.inputmethod.InputMethodManager; + +import java.security.KeyPairGenerator; +import java.security.KeyStore; +import java.security.KeyStoreException; +import java.security.NoSuchAlgorithmException; +import java.security.NoSuchProviderException; +import java.security.Signature; + +import dagger.Module; +import dagger.Provides; + +/** + * Dagger module for Fingerprint APIs. + */ +@Module( + library = true, + injects = {MainActivity.class} +) +public class FingerprintModule { + + private final Context mContext; + + public FingerprintModule(Context context) { + mContext = context; + } + + @Provides + public Context providesContext() { + return mContext; + } + + @Provides + public FingerprintManager providesFingerprintManager(Context context) { + return context.getSystemService(FingerprintManager.class); + } + + @Provides + public KeyguardManager providesKeyguardManager(Context context) { + return context.getSystemService(KeyguardManager.class); + } + + @Provides + public KeyStore providesKeystore() { + try { + return KeyStore.getInstance("AndroidKeyStore"); + } catch (KeyStoreException e) { + throw new RuntimeException("Failed to get an instance of KeyStore", e); + } + } + + @Provides + public KeyPairGenerator providesKeyPairGenerator() { + try { + return KeyPairGenerator.getInstance(KeyProperties.KEY_ALGORITHM_EC, "AndroidKeyStore"); + } catch (NoSuchAlgorithmException | NoSuchProviderException e) { + throw new RuntimeException("Failed to get an instance of KeyPairGenerator", e); + } + } + + @Provides + public Signature providesSignature(KeyStore keyStore) { + try { + return Signature.getInstance("SHA256withECDSA"); + } catch (NoSuchAlgorithmException e) { + throw new RuntimeException("Failed to get an instance of Signature", e); + } + } + + @Provides + public InputMethodManager providesInputMethodManager(Context context) { + return (InputMethodManager) context.getSystemService(Context.INPUT_METHOD_SERVICE); + } + + @Provides + public SharedPreferences providesSharedPreferences(Context context) { + return PreferenceManager.getDefaultSharedPreferences(context); + } + + @Provides + public StoreBackend providesStoreBackend() { + return new StoreBackendImpl(); + } +} diff --git a/samples/browseable/AsymmetricFingerprintDialog/src/com.example.android.asymmetricfingerprintdialog/FingerprintUiHelper.java b/samples/browseable/AsymmetricFingerprintDialog/src/com.example.android.asymmetricfingerprintdialog/FingerprintUiHelper.java new file mode 100644 index 000000000..f65481161 --- /dev/null +++ b/samples/browseable/AsymmetricFingerprintDialog/src/com.example.android.asymmetricfingerprintdialog/FingerprintUiHelper.java @@ -0,0 +1,165 @@ +/* + * Copyright (C) 2015 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License + */ + +package com.example.android.asymmetricfingerprintdialog; + +import com.google.common.annotations.VisibleForTesting; + +import android.hardware.fingerprint.FingerprintManager; +import android.os.CancellationSignal; +import android.widget.ImageView; +import android.widget.TextView; + +import javax.inject.Inject; + +/** + * Small helper class to manage text/icon around fingerprint authentication UI. + */ +public class FingerprintUiHelper extends FingerprintManager.AuthenticationCallback { + + @VisibleForTesting static final long ERROR_TIMEOUT_MILLIS = 1600; + @VisibleForTesting static final long SUCCESS_DELAY_MILLIS = 1300; + + private final FingerprintManager mFingerprintManager; + private final ImageView mIcon; + private final TextView mErrorTextView; + private final Callback mCallback; + private CancellationSignal mCancellationSignal; + + @VisibleForTesting boolean mSelfCancelled; + + /** + * Builder class for {@link FingerprintUiHelper} in which injected fields from Dagger + * holds its fields and takes other arguments in the {@link #build} method. + */ + public static class FingerprintUiHelperBuilder { + private final FingerprintManager mFingerPrintManager; + + @Inject + public FingerprintUiHelperBuilder(FingerprintManager fingerprintManager) { + mFingerPrintManager = fingerprintManager; + } + + public FingerprintUiHelper build(ImageView icon, TextView errorTextView, Callback callback) { + return new FingerprintUiHelper(mFingerPrintManager, icon, errorTextView, + callback); + } + } + + /** + * Constructor for {@link FingerprintUiHelper}. This method is expected to be called from + * only the {@link FingerprintUiHelperBuilder} class. + */ + private FingerprintUiHelper(FingerprintManager fingerprintManager, + ImageView icon, TextView errorTextView, Callback callback) { + mFingerprintManager = fingerprintManager; + mIcon = icon; + mErrorTextView = errorTextView; + mCallback = callback; + } + + public boolean isFingerprintAuthAvailable() { + return mFingerprintManager.isHardwareDetected() + && mFingerprintManager.hasEnrolledFingerprints(); + } + + public void startListening(FingerprintManager.CryptoObject cryptoObject) { + if (!isFingerprintAuthAvailable()) { + return; + } + mCancellationSignal = new CancellationSignal(); + mSelfCancelled = false; + mFingerprintManager + .authenticate(cryptoObject, mCancellationSignal, 0 /* flags */, this, null); + mIcon.setImageResource(R.drawable.ic_fp_40px); + } + + public void stopListening() { + if (mCancellationSignal != null) { + mSelfCancelled = true; + mCancellationSignal.cancel(); + mCancellationSignal = null; + } + } + + @Override + public void onAuthenticationError(int errMsgId, CharSequence errString) { + if (!mSelfCancelled) { + showError(errString); + mIcon.postDelayed(new Runnable() { + @Override + public void run() { + mCallback.onError(); + } + }, ERROR_TIMEOUT_MILLIS); + } + } + + @Override + public void onAuthenticationHelp(int helpMsgId, CharSequence helpString) { + showError(helpString); + } + + @Override + public void onAuthenticationFailed() { + showError(mIcon.getResources().getString( + R.string.fingerprint_not_recognized)); + } + + @Override + public void onAuthenticationSucceeded(FingerprintManager.AuthenticationResult result) { + mErrorTextView.removeCallbacks(mResetErrorTextRunnable); + mIcon.setImageResource(R.drawable.ic_fingerprint_success); + mErrorTextView.setTextColor( + mErrorTextView.getResources().getColor(R.color.success_color, null)); + mErrorTextView.setText( + mErrorTextView.getResources().getString(R.string.fingerprint_success)); + mIcon.postDelayed(new Runnable() { + @Override + public void run() { + mCallback.onAuthenticated(); + } + }, SUCCESS_DELAY_MILLIS); + } + + private void showError(CharSequence error) { + mIcon.setImageResource(R.drawable.ic_fingerprint_error); + mErrorTextView.setText(error); + mErrorTextView.setTextColor( + mErrorTextView.getResources().getColor(R.color.warning_color, null)); + mErrorTextView.removeCallbacks(mResetErrorTextRunnable); + mErrorTextView.postDelayed(mResetErrorTextRunnable, ERROR_TIMEOUT_MILLIS); + } + + @VisibleForTesting + Runnable mResetErrorTextRunnable = new Runnable() { + @Override + public void run() { + mErrorTextView.setTextColor( + mErrorTextView.getResources().getColor(R.color.hint_color, null)); + mErrorTextView.setText( + mErrorTextView.getResources().getString(R.string.fingerprint_hint)); + mIcon.setImageResource(R.drawable.ic_fp_40px); + } + }; + + public interface Callback { + + void onAuthenticated(); + + void onError(); + } +} diff --git a/samples/browseable/AsymmetricFingerprintDialog/src/com.example.android.asymmetricfingerprintdialog/InjectedApplication.java b/samples/browseable/AsymmetricFingerprintDialog/src/com.example.android.asymmetricfingerprintdialog/InjectedApplication.java new file mode 100644 index 000000000..1c3ed7e20 --- /dev/null +++ b/samples/browseable/AsymmetricFingerprintDialog/src/com.example.android.asymmetricfingerprintdialog/InjectedApplication.java @@ -0,0 +1,59 @@ +/* + * Copyright (C) 2015 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License + */ + +package com.example.android.asymmetricfingerprintdialog; + +import android.app.Application; +import android.util.Log; + +import dagger.ObjectGraph; + +/** + * The Application class of the sample which holds the ObjectGraph in Dagger and enables + * dependency injection. + */ +public class InjectedApplication extends Application { + + private static final String TAG = InjectedApplication.class.getSimpleName(); + + private ObjectGraph mObjectGraph; + + @Override + public void onCreate() { + super.onCreate(); + + initObjectGraph(new FingerprintModule(this)); + } + + /** + * Initialize the Dagger module. Passing null or mock modules can be used for testing. + * + * @param module for Dagger + */ + public void initObjectGraph(Object module) { + mObjectGraph = module != null ? ObjectGraph.create(module) : null; + } + + public void inject(Object object) { + if (mObjectGraph == null) { + // This usually happens during tests. + Log.i(TAG, "Object graph is not initialized."); + return; + } + mObjectGraph.inject(object); + } + +} diff --git a/samples/browseable/AsymmetricFingerprintDialog/src/com.example.android.asymmetricfingerprintdialog/MainActivity.java b/samples/browseable/AsymmetricFingerprintDialog/src/com.example.android.asymmetricfingerprintdialog/MainActivity.java new file mode 100644 index 000000000..26832f2c5 --- /dev/null +++ b/samples/browseable/AsymmetricFingerprintDialog/src/com.example.android.asymmetricfingerprintdialog/MainActivity.java @@ -0,0 +1,215 @@ +/* + * Copyright (C) 2015 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License + */ + +package com.example.android.asymmetricfingerprintdialog; + +import android.app.Activity; +import android.app.KeyguardManager; +import android.content.Intent; +import android.content.SharedPreferences; +import android.hardware.fingerprint.FingerprintManager; +import android.os.Bundle; +import android.security.keystore.KeyGenParameterSpec; +import android.security.keystore.KeyPermanentlyInvalidatedException; +import android.security.keystore.KeyProperties; +import android.util.Base64; +import android.view.Menu; +import android.view.MenuItem; +import android.view.View; +import android.widget.Button; +import android.widget.TextView; +import android.widget.Toast; + +import java.io.IOException; +import java.security.InvalidAlgorithmParameterException; +import java.security.InvalidKeyException; +import java.security.KeyPairGenerator; +import java.security.KeyStore; +import java.security.KeyStoreException; +import java.security.NoSuchAlgorithmException; +import java.security.PrivateKey; +import java.security.Signature; +import java.security.UnrecoverableKeyException; +import java.security.cert.CertificateException; +import java.security.spec.ECGenParameterSpec; + +import javax.inject.Inject; + +/** + * Main entry point for the sample, showing a backpack and "Purchase" button. + */ +public class MainActivity extends Activity { + + private static final String DIALOG_FRAGMENT_TAG = "myFragment"; + /** Alias for our key in the Android Key Store */ + public static final String KEY_NAME = "my_key"; + + @Inject KeyguardManager mKeyguardManager; + @Inject FingerprintManager mFingerprintManager; + @Inject FingerprintAuthenticationDialogFragment mFragment; + @Inject KeyStore mKeyStore; + @Inject KeyPairGenerator mKeyPairGenerator; + @Inject Signature mSignature; + @Inject SharedPreferences mSharedPreferences; + + @Override + protected void onCreate(Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + ((InjectedApplication) getApplication()).inject(this); + + setContentView(R.layout.activity_main); + Button purchaseButton = (Button) findViewById(R.id.purchase_button); + if (!mKeyguardManager.isKeyguardSecure()) { + // Show a message that the user hasn't set up a fingerprint or lock screen. + Toast.makeText(this, + "Secure lock screen hasn't set up.\n" + + "Go to 'Settings -> Security -> Fingerprint' to set up a fingerprint", + Toast.LENGTH_LONG).show(); + purchaseButton.setEnabled(false); + return; + } + //noinspection ResourceType + if (!mFingerprintManager.hasEnrolledFingerprints()) { + purchaseButton.setEnabled(false); + // This happens when no fingerprints are registered. + Toast.makeText(this, + "Go to 'Settings -> Security -> Fingerprint' and register at least one fingerprint", + Toast.LENGTH_LONG).show(); + return; + } + createKeyPair(); + purchaseButton.setEnabled(true); + purchaseButton.setOnClickListener(new View.OnClickListener() { + @Override + public void onClick(View v) { + findViewById(R.id.confirmation_message).setVisibility(View.GONE); + findViewById(R.id.encrypted_message).setVisibility(View.GONE); + + // Set up the crypto object for later. The object will be authenticated by use + // of the fingerprint. + if (initSignature()) { + + // Show the fingerprint dialog. The user has the option to use the fingerprint with + // crypto, or you can fall back to using a server-side verified password. + mFragment.setCryptoObject(new FingerprintManager.CryptoObject(mSignature)); + boolean useFingerprintPreference = mSharedPreferences + .getBoolean(getString(R.string.use_fingerprint_to_authenticate_key), + true); + if (useFingerprintPreference) { + mFragment.setStage( + FingerprintAuthenticationDialogFragment.Stage.FINGERPRINT); + } else { + mFragment.setStage( + FingerprintAuthenticationDialogFragment.Stage.PASSWORD); + } + mFragment.show(getFragmentManager(), DIALOG_FRAGMENT_TAG); + } else { + // This happens if the lock screen has been disabled or or a fingerprint got + // enrolled. Thus show the dialog to authenticate with their password first + // and ask the user if they want to authenticate with fingerprints in the + // future + mFragment.setStage( + FingerprintAuthenticationDialogFragment.Stage.NEW_FINGERPRINT_ENROLLED); + mFragment.show(getFragmentManager(), DIALOG_FRAGMENT_TAG); + } + } + }); + } + + /** + * Initialize the {@link Signature} instance with the created key in the + * {@link #createKeyPair()} method. + * + * @return {@code true} if initialization is successful, {@code false} if the lock screen has + * been disabled or reset after the key was generated, or if a fingerprint got enrolled after + * the key was generated. + */ + private boolean initSignature() { + try { + mKeyStore.load(null); + PrivateKey key = (PrivateKey) mKeyStore.getKey(KEY_NAME, null); + mSignature.initSign(key); + return true; + } catch (KeyPermanentlyInvalidatedException e) { + return false; + } catch (KeyStoreException | CertificateException | UnrecoverableKeyException | IOException + | NoSuchAlgorithmException | InvalidKeyException e) { + throw new RuntimeException("Failed to init Cipher", e); + } + } + + public void onPurchased(byte[] signature) { + showConfirmation(signature); + } + + public void onPurchaseFailed() { + Toast.makeText(this, R.string.purchase_fail, Toast.LENGTH_SHORT).show(); + } + + // Show confirmation, if fingerprint was used show crypto information. + private void showConfirmation(byte[] encrypted) { + findViewById(R.id.confirmation_message).setVisibility(View.VISIBLE); + if (encrypted != null) { + TextView v = (TextView) findViewById(R.id.encrypted_message); + v.setVisibility(View.VISIBLE); + v.setText(Base64.encodeToString(encrypted, 0 /* flags */)); + } + } + + /** + * Generates an asymmetric key pair in the Android Keystore. Every use of the private key must + * be authorized by the user authenticating with fingerprint. Public key use is unrestricted. + */ + public void createKeyPair() { + // The enrolling flow for fingerprint. This is where you ask the user to set up fingerprint + // for your flow. Use of keys is necessary if you need to know if the set of + // enrolled fingerprints has changed. + try { + // Set the alias of the entry in Android KeyStore where the key will appear + // and the constrains (purposes) in the constructor of the Builder + mKeyPairGenerator.initialize( + new KeyGenParameterSpec.Builder(KEY_NAME, + KeyProperties.PURPOSE_SIGN) + .setDigests(KeyProperties.DIGEST_SHA256) + .setAlgorithmParameterSpec(new ECGenParameterSpec("secp256r1")) + // Require the user to authenticate with a fingerprint to authorize + // every use of the private key + .setUserAuthenticationRequired(true) + .build()); + mKeyPairGenerator.generateKeyPair(); + } catch (InvalidAlgorithmParameterException e) { + throw new RuntimeException(e); + } + } + + @Override + public boolean onCreateOptionsMenu(Menu menu) { + getMenuInflater().inflate(R.menu.menu_main, menu); + return true; + } + + @Override + public boolean onOptionsItemSelected(MenuItem item) { + int id = item.getItemId(); + + if (id == R.id.action_settings) { + Intent intent = new Intent(this, SettingsActivity.class); + startActivity(intent); + return true; + } + return super.onOptionsItemSelected(item); + } +} diff --git a/samples/browseable/AsymmetricFingerprintDialog/src/com.example.android.asymmetricfingerprintdialog/SettingsActivity.java b/samples/browseable/AsymmetricFingerprintDialog/src/com.example.android.asymmetricfingerprintdialog/SettingsActivity.java new file mode 100644 index 000000000..acc5e378c --- /dev/null +++ b/samples/browseable/AsymmetricFingerprintDialog/src/com.example.android.asymmetricfingerprintdialog/SettingsActivity.java @@ -0,0 +1,47 @@ +/* + * Copyright (C) 2015 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License + */ + +package com.example.android.asymmetricfingerprintdialog; + +import android.app.Activity; +import android.os.Bundle; +import android.preference.PreferenceFragment; + +public class SettingsActivity extends Activity { + + @Override + protected void onCreate(Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + + // Display the fragment as the main content. + getFragmentManager().beginTransaction().replace(android.R.id.content, + new SettingsFragment()).commit(); + } + + /** + * Fragment for settings. + */ + public static class SettingsFragment extends PreferenceFragment { + + @Override + public void onCreate(Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + addPreferencesFromResource(R.xml.preferences); + } + } +} + + diff --git a/samples/browseable/AsymmetricFingerprintDialog/src/com.example.android.asymmetricfingerprintdialog/server/StoreBackend.java b/samples/browseable/AsymmetricFingerprintDialog/src/com.example.android.asymmetricfingerprintdialog/server/StoreBackend.java new file mode 100644 index 000000000..87921ae5f --- /dev/null +++ b/samples/browseable/AsymmetricFingerprintDialog/src/com.example.android.asymmetricfingerprintdialog/server/StoreBackend.java @@ -0,0 +1,60 @@ +/* + * Copyright (C) 2015 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License + */ + +package com.example.android.asymmetricfingerprintdialog.server; + +import java.security.PublicKey; + +/** + * An interface that defines the methods required for the store backend. + */ +public interface StoreBackend { + + /** + * Verifies the authenticity of the provided transaction by confirming that it was signed with + * the private key enrolled for the userId. + * + * @param transaction the contents of the purchase transaction, its contents are + * signed + * by the + * private key in the client side. + * @param transactionSignature the signature of the transaction's contents. + * @return true if the signedSignature was verified, false otherwise. If this method returns + * true, the server can consider the transaction is successful. + */ + boolean verify(Transaction transaction, byte[] transactionSignature); + + /** + * Verifies the authenticity of the provided transaction by password. + * + * @param transaction the contents of the purchase transaction, its contents are signed by the + * private key in the client side. + * @param password the password for the user associated with the {@code transaction}. + * @return true if the password is verified. + */ + boolean verify(Transaction transaction, String password); + + /** + * Enrolls a public key associated with the userId + * + * @param userId the unique ID of the user within the app including server side + * implementation + * @param password the password for the user for the server side + * @param publicKey the public key object to verify the signature from the user + * @return true if the enrollment was successful, false otherwise + */ + boolean enroll(String userId, String password, PublicKey publicKey); +} diff --git a/samples/browseable/AsymmetricFingerprintDialog/src/com.example.android.asymmetricfingerprintdialog/server/StoreBackendImpl.java b/samples/browseable/AsymmetricFingerprintDialog/src/com.example.android.asymmetricfingerprintdialog/server/StoreBackendImpl.java new file mode 100644 index 000000000..b28dce493 --- /dev/null +++ b/samples/browseable/AsymmetricFingerprintDialog/src/com.example.android.asymmetricfingerprintdialog/server/StoreBackendImpl.java @@ -0,0 +1,77 @@ +/* + * Copyright (C) 2015 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License + */ + +package com.example.android.asymmetricfingerprintdialog.server; + + +import java.security.InvalidKeyException; +import java.security.NoSuchAlgorithmException; +import java.security.PublicKey; +import java.security.Signature; +import java.security.SignatureException; +import java.util.HashMap; +import java.util.HashSet; +import java.util.Map; +import java.util.Set; + +/** + * A fake backend implementation of {@link StoreBackend}. + */ +public class StoreBackendImpl implements StoreBackend { + + private final Map mPublicKeys = new HashMap<>(); + private final Set mReceivedTransactions = new HashSet<>(); + + @Override + public boolean verify(Transaction transaction, byte[] transactionSignature) { + try { + if (mReceivedTransactions.contains(transaction)) { + // It verifies the equality of the transaction including the client nonce + // So attackers can't do replay attacks. + return false; + } + mReceivedTransactions.add(transaction); + PublicKey publicKey = mPublicKeys.get(transaction.getUserId()); + Signature verificationFunction = Signature.getInstance("SHA256withECDSA"); + verificationFunction.initVerify(publicKey); + verificationFunction.update(transaction.toByteArray()); + if (verificationFunction.verify(transactionSignature)) { + // Transaction is verified with the public key associated with the user + // Do some post purchase processing in the server + return true; + } + } catch (NoSuchAlgorithmException | InvalidKeyException | SignatureException e) { + // In a real world, better to send some error message to the user + } + return false; + } + + @Override + public boolean verify(Transaction transaction, String password) { + // As this is just a sample, we always assume that the password is right. + return true; + } + + @Override + public boolean enroll(String userId, String password, PublicKey publicKey) { + if (publicKey != null) { + mPublicKeys.put(userId, publicKey); + } + // We just ignore the provided password here, but in real life, it is registered to the + // backend. + return true; + } +} diff --git a/samples/browseable/AsymmetricFingerprintDialog/src/com.example.android.asymmetricfingerprintdialog/server/Transaction.java b/samples/browseable/AsymmetricFingerprintDialog/src/com.example.android.asymmetricfingerprintdialog/server/Transaction.java new file mode 100644 index 000000000..789cc0e7c --- /dev/null +++ b/samples/browseable/AsymmetricFingerprintDialog/src/com.example.android.asymmetricfingerprintdialog/server/Transaction.java @@ -0,0 +1,94 @@ +/* + * Copyright (C) 2015 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License + */ + +package com.example.android.asymmetricfingerprintdialog.server; + +import java.io.ByteArrayOutputStream; +import java.io.DataOutputStream; +import java.io.IOException; +import java.util.Objects; + +/** + * An entity that represents a single transaction (purchase) of an item. + */ +public class Transaction { + + /** The unique ID of the item of the purchase */ + private final Long mItemId; + + /** The unique user ID who made the transaction */ + private final String mUserId; + + /** + * The random long value that will be also signed by the private key and verified in the server + * that the same nonce can't be reused to prevent replay attacks. + */ + private final Long mClientNonce; + + public Transaction(String userId, long itemId, long clientNonce) { + mItemId = itemId; + mUserId = userId; + mClientNonce = clientNonce; + } + + public String getUserId() { + return mUserId; + } + + public byte[] toByteArray() { + ByteArrayOutputStream byteArrayOutputStream = new ByteArrayOutputStream(); + DataOutputStream dataOutputStream = null; + try { + dataOutputStream = new DataOutputStream(byteArrayOutputStream); + dataOutputStream.writeLong(mItemId); + dataOutputStream.writeUTF(mUserId); + dataOutputStream.writeLong(mClientNonce); + return byteArrayOutputStream.toByteArray(); + } catch (IOException e) { + throw new RuntimeException(e); + } finally { + try { + if (dataOutputStream != null) { + dataOutputStream.close(); + } + } catch (IOException ignore) { + } + try { + byteArrayOutputStream.close(); + } catch (IOException ignore) { + } + } + } + + @Override + public boolean equals(Object o) { + if (this == o) { + return true; + } + if (o == null || getClass() != o.getClass()) { + return false; + } + + Transaction that = (Transaction) o; + return Objects.equals(mItemId, that.mItemId) && Objects.equals(mUserId, that.mUserId) && + Objects.equals(mClientNonce, that.mClientNonce); + } + + @Override + public int hashCode() { + return Objects.hash(mItemId, mUserId, mClientNonce); + } +} diff --git a/samples/browseable/AutoBackupForApps/res/values-v11/template-styles.xml b/samples/browseable/AutoBackupForApps/res/values-v11/template-styles.xml index f261bb5c2..8c1ea66f2 100644 --- a/samples/browseable/AutoBackupForApps/res/values-v11/template-styles.xml +++ b/samples/browseable/AutoBackupForApps/res/values-v11/template-styles.xml @@ -17,6 +17,6 @@ - diff --git a/samples/browseable/AutoBackupForApps/res/values/template-styles.xml b/samples/browseable/AutoBackupForApps/res/values/template-styles.xml index 74086d293..6e7d593dd 100644 --- a/samples/browseable/AutoBackupForApps/res/values/template-styles.xml +++ b/samples/browseable/AutoBackupForApps/res/values/template-styles.xml @@ -18,7 +18,7 @@ - diff --git a/samples/browseable/BasicAccessibility/res/values/template-styles.xml b/samples/browseable/BasicAccessibility/res/values/template-styles.xml index 74086d293..6e7d593dd 100644 --- a/samples/browseable/BasicAccessibility/res/values/template-styles.xml +++ b/samples/browseable/BasicAccessibility/res/values/template-styles.xml @@ -18,7 +18,7 @@ - diff --git a/samples/browseable/BasicAndroidKeyStore/src/com.example.android.basicandroidkeystore/BasicAndroidKeyStoreFragment.java b/samples/browseable/BasicAndroidKeyStore/src/com.example.android.basicandroidkeystore/BasicAndroidKeyStoreFragment.java index 12873e847..e6244bfb6 100644 --- a/samples/browseable/BasicAndroidKeyStore/src/com.example.android.basicandroidkeystore/BasicAndroidKeyStoreFragment.java +++ b/samples/browseable/BasicAndroidKeyStore/src/com.example.android.basicandroidkeystore/BasicAndroidKeyStoreFragment.java @@ -156,7 +156,7 @@ public class BasicAndroidKeyStoreFragment extends Fragment { // generated. Calendar start = new GregorianCalendar(); Calendar end = new GregorianCalendar(); - end.add(1, Calendar.YEAR); + end.add(Calendar.YEAR, 1); //END_INCLUDE(create_valid_dates) @@ -316,8 +316,7 @@ public class BasicAndroidKeyStoreFragment extends Fragment { // Verify the data. s.initVerify(((KeyStore.PrivateKeyEntry) entry).getCertificate()); s.update(data); - boolean valid = s.verify(signature); - return valid; + return s.verify(signature); // END_INCLUDE(verify_data) } diff --git a/samples/browseable/BasicContactables/res/values-v11/template-styles.xml b/samples/browseable/BasicContactables/res/values-v11/template-styles.xml index f261bb5c2..8c1ea66f2 100644 --- a/samples/browseable/BasicContactables/res/values-v11/template-styles.xml +++ b/samples/browseable/BasicContactables/res/values-v11/template-styles.xml @@ -17,6 +17,6 @@ - diff --git a/samples/browseable/BasicContactables/res/values/template-styles.xml b/samples/browseable/BasicContactables/res/values/template-styles.xml index 74086d293..6e7d593dd 100644 --- a/samples/browseable/BasicContactables/res/values/template-styles.xml +++ b/samples/browseable/BasicContactables/res/values/template-styles.xml @@ -18,7 +18,7 @@ - diff --git a/samples/browseable/BasicImmersiveMode/res/values-v11/template-styles.xml b/samples/browseable/BasicImmersiveMode/res/values-v11/template-styles.xml index f261bb5c2..8c1ea66f2 100644 --- a/samples/browseable/BasicImmersiveMode/res/values-v11/template-styles.xml +++ b/samples/browseable/BasicImmersiveMode/res/values-v11/template-styles.xml @@ -17,6 +17,6 @@ - diff --git a/samples/browseable/BasicManagedProfile/res/values-v11/template-styles.xml b/samples/browseable/BasicManagedProfile/res/values-v11/template-styles.xml index f261bb5c2..8c1ea66f2 100644 --- a/samples/browseable/BasicManagedProfile/res/values-v11/template-styles.xml +++ b/samples/browseable/BasicManagedProfile/res/values-v11/template-styles.xml @@ -17,6 +17,6 @@ - diff --git a/samples/browseable/BasicManagedProfile/res/values/template-styles.xml b/samples/browseable/BasicManagedProfile/res/values/template-styles.xml index 74086d293..6e7d593dd 100644 --- a/samples/browseable/BasicManagedProfile/res/values/template-styles.xml +++ b/samples/browseable/BasicManagedProfile/res/values/template-styles.xml @@ -18,7 +18,7 @@ - diff --git a/samples/browseable/BasicMediaDecoder/res/values/template-styles.xml b/samples/browseable/BasicMediaDecoder/res/values/template-styles.xml index 74086d293..6e7d593dd 100644 --- a/samples/browseable/BasicMediaDecoder/res/values/template-styles.xml +++ b/samples/browseable/BasicMediaDecoder/res/values/template-styles.xml @@ -18,7 +18,7 @@ - diff --git a/samples/browseable/BasicMediaRouter/res/values/template-styles.xml b/samples/browseable/BasicMediaRouter/res/values/template-styles.xml index 74086d293..6e7d593dd 100644 --- a/samples/browseable/BasicMediaRouter/res/values/template-styles.xml +++ b/samples/browseable/BasicMediaRouter/res/values/template-styles.xml @@ -18,7 +18,7 @@ - diff --git a/samples/browseable/BasicMultitouch/res/values/template-styles.xml b/samples/browseable/BasicMultitouch/res/values/template-styles.xml index 74086d293..6e7d593dd 100644 --- a/samples/browseable/BasicMultitouch/res/values/template-styles.xml +++ b/samples/browseable/BasicMultitouch/res/values/template-styles.xml @@ -18,7 +18,7 @@ - diff --git a/samples/browseable/BasicNetworking/res/values/template-styles.xml b/samples/browseable/BasicNetworking/res/values/template-styles.xml index 74086d293..6e7d593dd 100644 --- a/samples/browseable/BasicNetworking/res/values/template-styles.xml +++ b/samples/browseable/BasicNetworking/res/values/template-styles.xml @@ -18,7 +18,7 @@ - diff --git a/samples/browseable/BasicNotifications/res/values/template-styles.xml b/samples/browseable/BasicNotifications/res/values/template-styles.xml index 74086d293..6e7d593dd 100644 --- a/samples/browseable/BasicNotifications/res/values/template-styles.xml +++ b/samples/browseable/BasicNotifications/res/values/template-styles.xml @@ -18,7 +18,7 @@ - diff --git a/samples/browseable/BasicRenderScript/res/values/template-styles.xml b/samples/browseable/BasicRenderScript/res/values/template-styles.xml index 74086d293..6e7d593dd 100644 --- a/samples/browseable/BasicRenderScript/res/values/template-styles.xml +++ b/samples/browseable/BasicRenderScript/res/values/template-styles.xml @@ -18,7 +18,7 @@ - diff --git a/samples/browseable/BasicSyncAdapter/res/values/template-styles.xml b/samples/browseable/BasicSyncAdapter/res/values/template-styles.xml index 74086d293..6e7d593dd 100644 --- a/samples/browseable/BasicSyncAdapter/res/values/template-styles.xml +++ b/samples/browseable/BasicSyncAdapter/res/values/template-styles.xml @@ -18,7 +18,7 @@ - diff --git a/samples/browseable/BasicTransition/res/values/template-styles.xml b/samples/browseable/BasicTransition/res/values/template-styles.xml index 74086d293..6e7d593dd 100644 --- a/samples/browseable/BasicTransition/res/values/template-styles.xml +++ b/samples/browseable/BasicTransition/res/values/template-styles.xml @@ -18,7 +18,7 @@ - diff --git a/samples/browseable/BatchStepSensor/res/values/template-styles.xml b/samples/browseable/BatchStepSensor/res/values/template-styles.xml index 74086d293..6e7d593dd 100644 --- a/samples/browseable/BatchStepSensor/res/values/template-styles.xml +++ b/samples/browseable/BatchStepSensor/res/values/template-styles.xml @@ -18,7 +18,7 @@ - diff --git a/samples/browseable/BluetoothAdvertisements/res/values-v11/template-styles.xml b/samples/browseable/BluetoothAdvertisements/res/values-v11/template-styles.xml index f261bb5c2..8c1ea66f2 100644 --- a/samples/browseable/BluetoothAdvertisements/res/values-v11/template-styles.xml +++ b/samples/browseable/BluetoothAdvertisements/res/values-v11/template-styles.xml @@ -17,6 +17,6 @@ - diff --git a/samples/browseable/BluetoothChat/res/values/template-styles.xml b/samples/browseable/BluetoothChat/res/values/template-styles.xml index 74086d293..6e7d593dd 100644 --- a/samples/browseable/BluetoothChat/res/values/template-styles.xml +++ b/samples/browseable/BluetoothChat/res/values/template-styles.xml @@ -18,7 +18,7 @@ - diff --git a/samples/browseable/BluetoothLeGatt/res/values/template-styles.xml b/samples/browseable/BluetoothLeGatt/res/values/template-styles.xml index 74086d293..6e7d593dd 100644 --- a/samples/browseable/BluetoothLeGatt/res/values/template-styles.xml +++ b/samples/browseable/BluetoothLeGatt/res/values/template-styles.xml @@ -18,7 +18,7 @@ - diff --git a/samples/browseable/BorderlessButtons/res/values/template-styles.xml b/samples/browseable/BorderlessButtons/res/values/template-styles.xml index 74086d293..6e7d593dd 100644 --- a/samples/browseable/BorderlessButtons/res/values/template-styles.xml +++ b/samples/browseable/BorderlessButtons/res/values/template-styles.xml @@ -18,7 +18,7 @@ - diff --git a/samples/browseable/Camera2Basic/res/values/strings.xml b/samples/browseable/Camera2Basic/res/values/strings.xml index 66f10008a..7fdf6e607 100644 --- a/samples/browseable/Camera2Basic/res/values/strings.xml +++ b/samples/browseable/Camera2Basic/res/values/strings.xml @@ -16,4 +16,6 @@ Picture Info + This sample needs camera permission. + This device doesn\'t support Camera2 API. diff --git a/samples/browseable/Camera2Basic/res/values/template-styles.xml b/samples/browseable/Camera2Basic/res/values/template-styles.xml index 74086d293..6e7d593dd 100644 --- a/samples/browseable/Camera2Basic/res/values/template-styles.xml +++ b/samples/browseable/Camera2Basic/res/values/template-styles.xml @@ -18,7 +18,7 @@ - diff --git a/samples/browseable/Camera2Raw/res/values/strings.xml b/samples/browseable/Camera2Raw/res/values/strings.xml index 0f56ce9bb..84672a082 100644 --- a/samples/browseable/Camera2Raw/res/values/strings.xml +++ b/samples/browseable/Camera2Raw/res/values/strings.xml @@ -16,4 +16,5 @@ Picture Info + This app needs camera permission. diff --git a/samples/browseable/Camera2Raw/res/values/template-styles.xml b/samples/browseable/Camera2Raw/res/values/template-styles.xml index 74086d293..6e7d593dd 100644 --- a/samples/browseable/Camera2Raw/res/values/template-styles.xml +++ b/samples/browseable/Camera2Raw/res/values/template-styles.xml @@ -18,7 +18,7 @@ - diff --git a/samples/browseable/Camera2Video/res/values/strings.xml b/samples/browseable/Camera2Video/res/values/strings.xml index bf5e439da..bce323f1a 100644 --- a/samples/browseable/Camera2Video/res/values/strings.xml +++ b/samples/browseable/Camera2Video/res/values/strings.xml @@ -3,4 +3,6 @@ Record Stop Info + This sample needs permission for camera and audio recording. + This device doesn\'t support Camera2 API. \ No newline at end of file diff --git a/samples/browseable/Camera2Video/res/values/template-styles.xml b/samples/browseable/Camera2Video/res/values/template-styles.xml index 74086d293..6e7d593dd 100644 --- a/samples/browseable/Camera2Video/res/values/template-styles.xml +++ b/samples/browseable/Camera2Video/res/values/template-styles.xml @@ -18,7 +18,7 @@ - diff --git a/samples/browseable/CardEmulation/res/values/template-styles.xml b/samples/browseable/CardEmulation/res/values/template-styles.xml index 74086d293..6e7d593dd 100644 --- a/samples/browseable/CardEmulation/res/values/template-styles.xml +++ b/samples/browseable/CardEmulation/res/values/template-styles.xml @@ -18,7 +18,7 @@ - diff --git a/samples/browseable/CardReader/res/values/template-styles.xml b/samples/browseable/CardReader/res/values/template-styles.xml index 74086d293..6e7d593dd 100644 --- a/samples/browseable/CardReader/res/values/template-styles.xml +++ b/samples/browseable/CardReader/res/values/template-styles.xml @@ -18,7 +18,7 @@ - diff --git a/samples/browseable/CardView/res/values/template-styles.xml b/samples/browseable/CardView/res/values/template-styles.xml index 74086d293..6e7d593dd 100644 --- a/samples/browseable/CardView/res/values/template-styles.xml +++ b/samples/browseable/CardView/res/values/template-styles.xml @@ -18,7 +18,7 @@ - diff --git a/samples/browseable/ClippingBasic/res/values/template-styles.xml b/samples/browseable/ClippingBasic/res/values/template-styles.xml index 74086d293..6e7d593dd 100644 --- a/samples/browseable/ClippingBasic/res/values/template-styles.xml +++ b/samples/browseable/ClippingBasic/res/values/template-styles.xml @@ -18,7 +18,7 @@ - diff --git a/samples/browseable/ConfirmCredential/res/values/template-styles.xml b/samples/browseable/ConfirmCredential/res/values/template-styles.xml index 74086d293..6e7d593dd 100644 --- a/samples/browseable/ConfirmCredential/res/values/template-styles.xml +++ b/samples/browseable/ConfirmCredential/res/values/template-styles.xml @@ -18,7 +18,7 @@ - diff --git a/samples/browseable/CustomChoiceList/res/values/template-styles.xml b/samples/browseable/CustomChoiceList/res/values/template-styles.xml index 74086d293..6e7d593dd 100644 --- a/samples/browseable/CustomChoiceList/res/values/template-styles.xml +++ b/samples/browseable/CustomChoiceList/res/values/template-styles.xml @@ -18,7 +18,7 @@ - diff --git a/samples/browseable/CustomNotifications/res/values/template-styles.xml b/samples/browseable/CustomNotifications/res/values/template-styles.xml index 74086d293..6e7d593dd 100644 --- a/samples/browseable/CustomNotifications/res/values/template-styles.xml +++ b/samples/browseable/CustomNotifications/res/values/template-styles.xml @@ -18,7 +18,7 @@ - diff --git a/samples/browseable/CustomTransition/res/values/template-styles.xml b/samples/browseable/CustomTransition/res/values/template-styles.xml index 74086d293..6e7d593dd 100644 --- a/samples/browseable/CustomTransition/res/values/template-styles.xml +++ b/samples/browseable/CustomTransition/res/values/template-styles.xml @@ -18,7 +18,7 @@ - diff --git a/samples/browseable/DataLayer/Application/res/values/template-styles.xml b/samples/browseable/DataLayer/Application/res/values/template-styles.xml index 74086d293..6e7d593dd 100644 --- a/samples/browseable/DataLayer/Application/res/values/template-styles.xml +++ b/samples/browseable/DataLayer/Application/res/values/template-styles.xml @@ -18,7 +18,7 @@ - diff --git a/samples/browseable/DelayedConfirmation/Application/res/values/template-styles.xml b/samples/browseable/DelayedConfirmation/Application/res/values/template-styles.xml index 74086d293..6e7d593dd 100644 --- a/samples/browseable/DelayedConfirmation/Application/res/values/template-styles.xml +++ b/samples/browseable/DelayedConfirmation/Application/res/values/template-styles.xml @@ -18,7 +18,7 @@ - diff --git a/samples/browseable/DeviceOwner/res/values/template-styles.xml b/samples/browseable/DeviceOwner/res/values/template-styles.xml index 74086d293..6e7d593dd 100644 --- a/samples/browseable/DeviceOwner/res/values/template-styles.xml +++ b/samples/browseable/DeviceOwner/res/values/template-styles.xml @@ -18,7 +18,7 @@ - + + diff --git a/samples/browseable/DirectShare/res/values-v11/template-styles.xml b/samples/browseable/DirectShare/res/values-v11/template-styles.xml new file mode 100644 index 000000000..8c1ea66f2 --- /dev/null +++ b/samples/browseable/DirectShare/res/values-v11/template-styles.xml @@ -0,0 +1,22 @@ + + + + + + + + diff --git a/samples/browseable/DirectShare/res/values/base-strings.xml b/samples/browseable/DirectShare/res/values/base-strings.xml new file mode 100644 index 000000000..b3f15fbb1 --- /dev/null +++ b/samples/browseable/DirectShare/res/values/base-strings.xml @@ -0,0 +1,30 @@ + + + + + DirectShare + + + + diff --git a/samples/browseable/DirectShare/res/values/colors.xml b/samples/browseable/DirectShare/res/values/colors.xml new file mode 100644 index 000000000..c5a6a3db7 --- /dev/null +++ b/samples/browseable/DirectShare/res/values/colors.xml @@ -0,0 +1,26 @@ + + + + #3F51B5 + #303F9F + #C5CAE9 + #00BCD4 + #212121 + #727272 + #FFFFFF + #B6B6B6 + diff --git a/samples/browseable/DirectShare/res/values/dimens.xml b/samples/browseable/DirectShare/res/values/dimens.xml new file mode 100644 index 000000000..2d05f5d3b --- /dev/null +++ b/samples/browseable/DirectShare/res/values/dimens.xml @@ -0,0 +1,20 @@ + + + + 24dp + 8dp + diff --git a/samples/browseable/DirectShare/res/values/strings.xml b/samples/browseable/DirectShare/res/values/strings.xml new file mode 100644 index 000000000..ddc858a96 --- /dev/null +++ b/samples/browseable/DirectShare/res/values/strings.xml @@ -0,0 +1,39 @@ + + + + + + + + This app demonstrates how to implement Direct Share. Use some other app and share a text. + For your convenience, you can also use the input below to share the text. + + Hello! + Share + Send a message via: + + + + Sending a message + To: + Send + Body: + Edit your message. + Sent a message \"%1$s\" to %2$s. + Text to share + + diff --git a/samples/browseable/DirectShare/res/values/styles.xml b/samples/browseable/DirectShare/res/values/styles.xml new file mode 100644 index 000000000..ae312cc42 --- /dev/null +++ b/samples/browseable/DirectShare/res/values/styles.xml @@ -0,0 +1,31 @@ + + + + + + + + + diff --git a/samples/browseable/DirectShare/res/values/template-dimens.xml b/samples/browseable/DirectShare/res/values/template-dimens.xml new file mode 100644 index 000000000..39e710b5c --- /dev/null +++ b/samples/browseable/DirectShare/res/values/template-dimens.xml @@ -0,0 +1,32 @@ + + + + + + + 4dp + 8dp + 16dp + 32dp + 64dp + + + + @dimen/margin_medium + @dimen/margin_medium + + diff --git a/samples/browseable/DirectShare/res/values/template-styles.xml b/samples/browseable/DirectShare/res/values/template-styles.xml new file mode 100644 index 000000000..6e7d593dd --- /dev/null +++ b/samples/browseable/DirectShare/res/values/template-styles.xml @@ -0,0 +1,42 @@ + + + + + + + + + + + diff --git a/samples/browseable/DirectShare/src/com.example.android.directshare/Contact.java b/samples/browseable/DirectShare/src/com.example.android.directshare/Contact.java new file mode 100644 index 000000000..4a1665e36 --- /dev/null +++ b/samples/browseable/DirectShare/src/com.example.android.directshare/Contact.java @@ -0,0 +1,93 @@ +/* + * Copyright (C) 2015 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.example.android.directshare; + +/** + * Provides the list of dummy contacts. This sample implements this as constants, but real-life apps + * should use a database and such. + */ +public class Contact { + + /** + * The list of dummy contacts. + */ + public static final Contact[] CONTACTS = { + new Contact("Tereasa"), + new Contact("Chang"), + new Contact("Kory"), + new Contact("Clare"), + new Contact("Landon"), + new Contact("Kyle"), + new Contact("Deana"), + new Contact("Daria"), + new Contact("Melisa"), + new Contact("Sammie"), + }; + + /** + * The contact ID. + */ + public static final String ID = "contact_id"; + + /** + * Representative invalid contact ID. + */ + public static final int INVALID_ID = -1; + + /** + * The name of this contact. + */ + private final String mName; + + /** + * Instantiates a new {@link Contact}. + * + * @param name The name of the contact. + */ + public Contact(String name) { + mName = name; + } + + /** + * Finds a {@link Contact} specified by a contact ID. + * + * @param id The contact ID. This needs to be a valid ID. + * @return A {@link Contact} + */ + public static Contact byId(int id) { + return CONTACTS[id]; + } + + /** + * Gets the name of this contact. + * + * @return The name of this contact. + */ + public String getName() { + return mName; + } + + /** + * Gets the icon of this contact. + * + * @return The icon. + */ + public int getIcon() { + return R.mipmap.logo_avatar; + } + +} diff --git a/samples/browseable/DirectShare/src/com.example.android.directshare/ContactViewBinder.java b/samples/browseable/DirectShare/src/com.example.android.directshare/ContactViewBinder.java new file mode 100644 index 000000000..5287b1c79 --- /dev/null +++ b/samples/browseable/DirectShare/src/com.example.android.directshare/ContactViewBinder.java @@ -0,0 +1,37 @@ +/* + * Copyright (C) 2015 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.example.android.directshare; + +import android.widget.TextView; + +/** + * A simple utility to bind a {@link TextView} with a {@link Contact}. + */ +public class ContactViewBinder { + + /** + * Binds the {@code textView} with the specified {@code contact}. + * + * @param contact The contact. + * @param textView The TextView. + */ + public static void bind(Contact contact, TextView textView) { + textView.setText(contact.getName()); + textView.setCompoundDrawablesRelativeWithIntrinsicBounds(contact.getIcon(), 0, 0, 0); + } + +} diff --git a/samples/browseable/DirectShare/src/com.example.android.directshare/MainActivity.java b/samples/browseable/DirectShare/src/com.example.android.directshare/MainActivity.java new file mode 100644 index 000000000..d68018621 --- /dev/null +++ b/samples/browseable/DirectShare/src/com.example.android.directshare/MainActivity.java @@ -0,0 +1,64 @@ +/* + * Copyright (C) 2015 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.example.android.directshare; + +import android.app.Activity; +import android.content.Intent; +import android.os.Bundle; +import android.view.View; +import android.widget.EditText; +import android.widget.Toolbar; + +/** + * Provides the landing screen of this sample. There is nothing particularly interesting here. All + * the codes related to the Direct Share feature are in {@link SampleChooserTargetService}. + */ +public class MainActivity extends Activity { + + private EditText mEditBody; + + @Override + protected void onCreate(Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + setContentView(R.layout.main); + setActionBar((Toolbar) findViewById(R.id.toolbar)); + mEditBody = (EditText) findViewById(R.id.body); + findViewById(R.id.share).setOnClickListener(mOnClickListener); + } + + private View.OnClickListener mOnClickListener = new View.OnClickListener() { + @Override + public void onClick(View v) { + switch (v.getId()) { + case R.id.share: + share(); + break; + } + } + }; + + /** + * Emits a sample share {@link Intent}. + */ + private void share() { + Intent sharingIntent = new Intent(android.content.Intent.ACTION_SEND); + sharingIntent.setType("text/plain"); + sharingIntent.putExtra(android.content.Intent.EXTRA_TEXT, mEditBody.getText().toString()); + startActivity(Intent.createChooser(sharingIntent, getString(R.string.send_intent_title))); + } + +} diff --git a/samples/browseable/DirectShare/src/com.example.android.directshare/SampleChooserTargetService.java b/samples/browseable/DirectShare/src/com.example.android.directshare/SampleChooserTargetService.java new file mode 100644 index 000000000..1e3259918 --- /dev/null +++ b/samples/browseable/DirectShare/src/com.example.android.directshare/SampleChooserTargetService.java @@ -0,0 +1,63 @@ +/* + * Copyright (C) 2015 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.example.android.directshare; + +import android.content.ComponentName; +import android.content.IntentFilter; +import android.graphics.drawable.Icon; +import android.os.Bundle; +import android.service.chooser.ChooserTarget; +import android.service.chooser.ChooserTargetService; + +import java.util.ArrayList; +import java.util.List; + +/** + * Provides the Direct Share items to the system. + */ +public class SampleChooserTargetService extends ChooserTargetService { + + @Override + public List onGetChooserTargets(ComponentName targetActivityName, + IntentFilter matchedFilter) { + ComponentName componentName = new ComponentName(getPackageName(), + SendMessageActivity.class.getCanonicalName()); + // The list of Direct Share items. The system will show the items the way they are sorted + // in this list. + ArrayList targets = new ArrayList<>(); + for (int i = 0; i < Contact.CONTACTS.length; ++i) { + Contact contact = Contact.byId(i); + Bundle extras = new Bundle(); + extras.putInt(Contact.ID, i); + targets.add(new ChooserTarget( + // The name of this target. + contact.getName(), + // The icon to represent this target. + Icon.createWithResource(this, contact.getIcon()), + // The ranking score for this target (0.0-1.0); the system will omit items with + // low scores when there are too many Direct Share items. + 0.5f, + // The name of the component to be launched if this target is chosen. + componentName, + // The extra values here will be merged into the Intent when this target is + // chosen. + extras)); + } + return targets; + } + +} diff --git a/samples/browseable/DirectShare/src/com.example.android.directshare/SelectContactActivity.java b/samples/browseable/DirectShare/src/com.example.android.directshare/SelectContactActivity.java new file mode 100644 index 000000000..440facb16 --- /dev/null +++ b/samples/browseable/DirectShare/src/com.example.android.directshare/SelectContactActivity.java @@ -0,0 +1,98 @@ +/* + * Copyright (C) 2015 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.example.android.directshare; + +import android.app.Activity; +import android.content.Intent; +import android.os.Bundle; +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; +import android.widget.AdapterView; +import android.widget.BaseAdapter; +import android.widget.ListAdapter; +import android.widget.ListView; +import android.widget.TextView; + +/** + * The dialog for selecting a contact to share the text with. This dialog is shown when the user + * taps on this sample's icon rather than any of the Direct Share contacts. + */ +public class SelectContactActivity extends Activity { + + /** + * The action string for Intents. + */ + public static final String ACTION_SELECT_CONTACT + = "com.example.android.directshare.intent.action.SELECT_CONTACT"; + + @Override + protected void onCreate(Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + setContentView(R.layout.select_contact); + Intent intent = getIntent(); + if (!ACTION_SELECT_CONTACT.equals(intent.getAction())) { + finish(); + return; + } + // Set up the list of contacts + ListView list = (ListView) findViewById(R.id.list); + list.setAdapter(mAdapter); + list.setOnItemClickListener(mOnItemClickListener); + } + + private final ListAdapter mAdapter = new BaseAdapter() { + @Override + public int getCount() { + return Contact.CONTACTS.length; + } + + @Override + public Object getItem(int i) { + return Contact.byId(i); + } + + @Override + public long getItemId(int i) { + return i; + } + + @Override + public View getView(int i, View view, ViewGroup parent) { + if (view == null) { + view = LayoutInflater.from(parent.getContext()).inflate(R.layout.contact, parent, + false); + } + TextView textView = (TextView) view; + Contact contact = (Contact) getItem(i); + ContactViewBinder.bind(contact, textView); + return textView; + } + }; + + private final AdapterView.OnItemClickListener mOnItemClickListener + = new AdapterView.OnItemClickListener() { + @Override + public void onItemClick(AdapterView adapterView, View view, int i, long l) { + Intent data = new Intent(); + data.putExtra(Contact.ID, i); + setResult(RESULT_OK, data); + finish(); + } + }; + +} diff --git a/samples/browseable/DirectShare/src/com.example.android.directshare/SendMessageActivity.java b/samples/browseable/DirectShare/src/com.example.android.directshare/SendMessageActivity.java new file mode 100644 index 000000000..d291172e6 --- /dev/null +++ b/samples/browseable/DirectShare/src/com.example.android.directshare/SendMessageActivity.java @@ -0,0 +1,153 @@ +/* + * Copyright (C) 2015 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.example.android.directshare; + +import android.app.Activity; +import android.content.Intent; +import android.os.Bundle; +import android.view.View; +import android.widget.TextView; +import android.widget.Toast; + +/** + * Provides the UI for sharing a text with a {@link Contact}. + */ +public class SendMessageActivity extends Activity { + + /** + * The request code for {@link SelectContactActivity}. This is used when the user doesn't select + * any of Direct Share icons. + */ + private static final int REQUEST_SELECT_CONTACT = 1; + + /** + * The text to share. + */ + private String mBody; + + /** + * The ID of the contact to share the text with. + */ + private int mContactId; + + // View references. + private TextView mTextContactName; + private TextView mTextMessageBody; + + @Override + protected void onCreate(Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + setContentView(R.layout.send_message); + setTitle(R.string.sending_message); + // View references. + mTextContactName = (TextView) findViewById(R.id.contact_name); + mTextMessageBody = (TextView) findViewById(R.id.message_body); + // Resolve the share Intent. + boolean resolved = resolveIntent(getIntent()); + if (!resolved) { + finish(); + return; + } + // Bind event handlers. + findViewById(R.id.send).setOnClickListener(mOnClickListener); + // Set up the UI. + prepareUi(); + // The contact ID will not be passed on when the user clicks on the app icon rather than any + // of the Direct Share icons. In this case, we show another dialog for selecting a contact. + if (mContactId == Contact.INVALID_ID) { + selectContact(); + } + } + + @Override + protected void onActivityResult(int requestCode, int resultCode, Intent data) { + switch (requestCode) { + case REQUEST_SELECT_CONTACT: + if (resultCode == RESULT_OK) { + mContactId = data.getIntExtra(Contact.ID, Contact.INVALID_ID); + } + // Give up sharing the send_message if the user didn't choose a contact. + if (mContactId == Contact.INVALID_ID) { + finish(); + return; + } + prepareUi(); + break; + default: + super.onActivityResult(requestCode, resultCode, data); + } + } + + /** + * Resolves the passed {@link Intent}. This method can only resolve intents for sharing a plain + * text. {@link #mBody} and {@link #mContactId} are modified accordingly. + * + * @param intent The {@link Intent}. + * @return True if the {@code intent} is resolved properly. + */ + private boolean resolveIntent(Intent intent) { + if (Intent.ACTION_SEND.equals(intent.getAction()) && + "text/plain".equals(intent.getType())) { + mBody = intent.getStringExtra(Intent.EXTRA_TEXT); + mContactId = intent.getIntExtra(Contact.ID, Contact.INVALID_ID); + return true; + } + return false; + } + + /** + * Sets up the UI. + */ + private void prepareUi() { + if (mContactId != Contact.INVALID_ID) { + Contact contact = Contact.byId(mContactId); + ContactViewBinder.bind(contact, mTextContactName); + } + mTextMessageBody.setText(mBody); + } + + /** + * Delegates selection of a {@Contact} to {@link SelectContactActivity}. + */ + private void selectContact() { + Intent intent = new Intent(this, SelectContactActivity.class); + intent.setAction(SelectContactActivity.ACTION_SELECT_CONTACT); + startActivityForResult(intent, REQUEST_SELECT_CONTACT); + } + + private View.OnClickListener mOnClickListener = new View.OnClickListener() { + @Override + public void onClick(View view) { + switch (view.getId()) { + case R.id.send: + send(); + break; + } + } + }; + + /** + * Pretends to send the text to the contact. This only shows a dummy message. + */ + private void send() { + Toast.makeText(this, + getString(R.string.message_sent, mBody, Contact.byId(mContactId).getName()), + Toast.LENGTH_SHORT).show(); + finish(); + } + +} diff --git a/samples/browseable/DirectorySelection/res/layout/fragment_directory_selection.xml b/samples/browseable/DirectorySelection/res/layout/fragment_directory_selection.xml index d63219c5d..631a8fc40 100644 --- a/samples/browseable/DirectorySelection/res/layout/fragment_directory_selection.xml +++ b/samples/browseable/DirectorySelection/res/layout/fragment_directory_selection.xml @@ -16,6 +16,7 @@ --> + android:layout_height="match_parent" + app:layoutManager="LinearLayoutManager" + /> diff --git a/samples/browseable/DirectorySelection/res/values-v11/template-styles.xml b/samples/browseable/DirectorySelection/res/values-v11/template-styles.xml index f261bb5c2..8c1ea66f2 100644 --- a/samples/browseable/DirectorySelection/res/values-v11/template-styles.xml +++ b/samples/browseable/DirectorySelection/res/values-v11/template-styles.xml @@ -17,6 +17,6 @@ - diff --git a/samples/browseable/DirectorySelection/res/values/template-styles.xml b/samples/browseable/DirectorySelection/res/values/template-styles.xml index 74086d293..6e7d593dd 100644 --- a/samples/browseable/DirectorySelection/res/values/template-styles.xml +++ b/samples/browseable/DirectorySelection/res/values/template-styles.xml @@ -18,7 +18,7 @@ - diff --git a/samples/browseable/DisplayingBitmaps/res/values/template-styles.xml b/samples/browseable/DisplayingBitmaps/res/values/template-styles.xml index 74086d293..6e7d593dd 100644 --- a/samples/browseable/DisplayingBitmaps/res/values/template-styles.xml +++ b/samples/browseable/DisplayingBitmaps/res/values/template-styles.xml @@ -18,7 +18,7 @@ - diff --git a/samples/browseable/DocumentCentricApps/res/values/template-styles.xml b/samples/browseable/DocumentCentricApps/res/values/template-styles.xml index 74086d293..6e7d593dd 100644 --- a/samples/browseable/DocumentCentricApps/res/values/template-styles.xml +++ b/samples/browseable/DocumentCentricApps/res/values/template-styles.xml @@ -18,7 +18,7 @@ - diff --git a/samples/browseable/DocumentCentricRelinquishIdentity/res/values/template-styles.xml b/samples/browseable/DocumentCentricRelinquishIdentity/res/values/template-styles.xml index 74086d293..6e7d593dd 100644 --- a/samples/browseable/DocumentCentricRelinquishIdentity/res/values/template-styles.xml +++ b/samples/browseable/DocumentCentricRelinquishIdentity/res/values/template-styles.xml @@ -18,7 +18,7 @@ - diff --git a/samples/browseable/DrawableTinting/res/values-v11/template-styles.xml b/samples/browseable/DrawableTinting/res/values-v11/template-styles.xml index f261bb5c2..8c1ea66f2 100644 --- a/samples/browseable/DrawableTinting/res/values-v11/template-styles.xml +++ b/samples/browseable/DrawableTinting/res/values-v11/template-styles.xml @@ -17,6 +17,6 @@ - diff --git a/samples/browseable/DrawableTinting/res/values/template-styles.xml b/samples/browseable/DrawableTinting/res/values/template-styles.xml index 74086d293..6e7d593dd 100644 --- a/samples/browseable/DrawableTinting/res/values/template-styles.xml +++ b/samples/browseable/DrawableTinting/res/values/template-styles.xml @@ -18,7 +18,7 @@ - diff --git a/samples/browseable/ElevationBasic/res/values/template-styles.xml b/samples/browseable/ElevationBasic/res/values/template-styles.xml index 74086d293..6e7d593dd 100644 --- a/samples/browseable/ElevationBasic/res/values/template-styles.xml +++ b/samples/browseable/ElevationBasic/res/values/template-styles.xml @@ -18,7 +18,7 @@ - diff --git a/samples/browseable/ElevationDrag/res/values/template-styles.xml b/samples/browseable/ElevationDrag/res/values/template-styles.xml index 74086d293..6e7d593dd 100644 --- a/samples/browseable/ElevationDrag/res/values/template-styles.xml +++ b/samples/browseable/ElevationDrag/res/values/template-styles.xml @@ -18,7 +18,7 @@ - diff --git a/samples/browseable/ElizaChat/res/values/template-styles.xml b/samples/browseable/ElizaChat/res/values/template-styles.xml index 74086d293..6e7d593dd 100644 --- a/samples/browseable/ElizaChat/res/values/template-styles.xml +++ b/samples/browseable/ElizaChat/res/values/template-styles.xml @@ -18,7 +18,7 @@ - diff --git a/samples/browseable/FindMyPhone/Application/res/values/template-styles.xml b/samples/browseable/FindMyPhone/Application/res/values/template-styles.xml index 74086d293..6e7d593dd 100644 --- a/samples/browseable/FindMyPhone/Application/res/values/template-styles.xml +++ b/samples/browseable/FindMyPhone/Application/res/values/template-styles.xml @@ -18,7 +18,7 @@ - diff --git a/samples/browseable/FingerprintDialog/res/values/base-strings.xml b/samples/browseable/FingerprintDialog/res/values/base-strings.xml index f3545f570..a4a93f7a4 100644 --- a/samples/browseable/FingerprintDialog/res/values/base-strings.xml +++ b/samples/browseable/FingerprintDialog/res/values/base-strings.xml @@ -16,7 +16,7 @@ --> - Fingerprint Dialog Sample + FingerprintDialog - diff --git a/samples/browseable/FloatingActionButtonBasic/res/values/template-styles.xml b/samples/browseable/FloatingActionButtonBasic/res/values/template-styles.xml index 74086d293..6e7d593dd 100644 --- a/samples/browseable/FloatingActionButtonBasic/res/values/template-styles.xml +++ b/samples/browseable/FloatingActionButtonBasic/res/values/template-styles.xml @@ -18,7 +18,7 @@ - diff --git a/samples/browseable/Geofencing/Application/res/values/template-styles.xml b/samples/browseable/Geofencing/Application/res/values/template-styles.xml index 74086d293..6e7d593dd 100644 --- a/samples/browseable/Geofencing/Application/res/values/template-styles.xml +++ b/samples/browseable/Geofencing/Application/res/values/template-styles.xml @@ -18,7 +18,7 @@ - diff --git a/samples/browseable/HdrViewfinder/res/values/template-styles.xml b/samples/browseable/HdrViewfinder/res/values/template-styles.xml index 74086d293..6e7d593dd 100644 --- a/samples/browseable/HdrViewfinder/res/values/template-styles.xml +++ b/samples/browseable/HdrViewfinder/res/values/template-styles.xml @@ -18,7 +18,7 @@ - diff --git a/samples/browseable/HorizontalPaging/res/values/template-styles.xml b/samples/browseable/HorizontalPaging/res/values/template-styles.xml index 74086d293..6e7d593dd 100644 --- a/samples/browseable/HorizontalPaging/res/values/template-styles.xml +++ b/samples/browseable/HorizontalPaging/res/values/template-styles.xml @@ -18,7 +18,7 @@ - diff --git a/samples/browseable/Interpolator/res/values-v11/template-styles.xml b/samples/browseable/Interpolator/res/values-v11/template-styles.xml index f261bb5c2..8c1ea66f2 100644 --- a/samples/browseable/Interpolator/res/values-v11/template-styles.xml +++ b/samples/browseable/Interpolator/res/values-v11/template-styles.xml @@ -17,6 +17,6 @@ - diff --git a/samples/browseable/Interpolator/res/values/template-styles.xml b/samples/browseable/Interpolator/res/values/template-styles.xml index 74086d293..6e7d593dd 100644 --- a/samples/browseable/Interpolator/res/values/template-styles.xml +++ b/samples/browseable/Interpolator/res/values/template-styles.xml @@ -18,7 +18,7 @@ - diff --git a/samples/browseable/JobScheduler/res/values/template-styles.xml b/samples/browseable/JobScheduler/res/values/template-styles.xml index 74086d293..6e7d593dd 100644 --- a/samples/browseable/JobScheduler/res/values/template-styles.xml +++ b/samples/browseable/JobScheduler/res/values/template-styles.xml @@ -18,7 +18,7 @@ - diff --git a/samples/browseable/LNotifications/res/values/template-styles.xml b/samples/browseable/LNotifications/res/values/template-styles.xml index 74086d293..6e7d593dd 100644 --- a/samples/browseable/LNotifications/res/values/template-styles.xml +++ b/samples/browseable/LNotifications/res/values/template-styles.xml @@ -18,7 +18,7 @@ - diff --git a/samples/browseable/MediaEffects/res/values/template-styles.xml b/samples/browseable/MediaEffects/res/values/template-styles.xml index 74086d293..6e7d593dd 100644 --- a/samples/browseable/MediaEffects/res/values/template-styles.xml +++ b/samples/browseable/MediaEffects/res/values/template-styles.xml @@ -18,7 +18,7 @@ - diff --git a/samples/browseable/MediaRecorder/res/values/template-styles.xml b/samples/browseable/MediaRecorder/res/values/template-styles.xml index 74086d293..6e7d593dd 100644 --- a/samples/browseable/MediaRecorder/res/values/template-styles.xml +++ b/samples/browseable/MediaRecorder/res/values/template-styles.xml @@ -18,7 +18,7 @@ - diff --git a/samples/browseable/MediaRouter/res/values/template-styles.xml b/samples/browseable/MediaRouter/res/values/template-styles.xml index 74086d293..6e7d593dd 100644 --- a/samples/browseable/MediaRouter/res/values/template-styles.xml +++ b/samples/browseable/MediaRouter/res/values/template-styles.xml @@ -18,7 +18,7 @@ - + + diff --git a/samples/browseable/MidiScope/res/values-v11/template-styles.xml b/samples/browseable/MidiScope/res/values-v11/template-styles.xml new file mode 100644 index 000000000..8c1ea66f2 --- /dev/null +++ b/samples/browseable/MidiScope/res/values-v11/template-styles.xml @@ -0,0 +1,22 @@ + + + + + + + + diff --git a/samples/browseable/MidiScope/res/values/base-strings.xml b/samples/browseable/MidiScope/res/values/base-strings.xml new file mode 100644 index 000000000..fb7d652fc --- /dev/null +++ b/samples/browseable/MidiScope/res/values/base-strings.xml @@ -0,0 +1,30 @@ + + + + + MidiScope + + + + diff --git a/samples/browseable/MidiScope/res/values/colors.xml b/samples/browseable/MidiScope/res/values/colors.xml new file mode 100644 index 000000000..eef48d880 --- /dev/null +++ b/samples/browseable/MidiScope/res/values/colors.xml @@ -0,0 +1,26 @@ + + + + #009688 + #00796B + #B2DFDB + #FFC107 + #212121 + #727272 + #FFFFFF + #B6B6B6 + diff --git a/samples/browseable/MidiScope/res/values/strings.xml b/samples/browseable/MidiScope/res/values/strings.xml new file mode 100644 index 000000000..52beb4e1a --- /dev/null +++ b/samples/browseable/MidiScope/res/values/strings.xml @@ -0,0 +1,24 @@ + + + + Select a MIDI source from the Spinner above or send messages to MidiScope. + Clear Log + Keep Screen On + + "none" + + diff --git a/samples/browseable/MidiScope/res/values/styles.xml b/samples/browseable/MidiScope/res/values/styles.xml new file mode 100644 index 000000000..003600969 --- /dev/null +++ b/samples/browseable/MidiScope/res/values/styles.xml @@ -0,0 +1,25 @@ + + + + + + + diff --git a/samples/browseable/MidiScope/res/values/template-dimens.xml b/samples/browseable/MidiScope/res/values/template-dimens.xml new file mode 100644 index 000000000..39e710b5c --- /dev/null +++ b/samples/browseable/MidiScope/res/values/template-dimens.xml @@ -0,0 +1,32 @@ + + + + + + + 4dp + 8dp + 16dp + 32dp + 64dp + + + + @dimen/margin_medium + @dimen/margin_medium + + diff --git a/samples/browseable/MidiScope/res/values/template-styles.xml b/samples/browseable/MidiScope/res/values/template-styles.xml new file mode 100644 index 000000000..6e7d593dd --- /dev/null +++ b/samples/browseable/MidiScope/res/values/template-styles.xml @@ -0,0 +1,42 @@ + + + + + + + + + + + diff --git a/samples/browseable/MidiScope/res/xml/scope_device_info.xml b/samples/browseable/MidiScope/res/xml/scope_device_info.xml new file mode 100644 index 000000000..f89f11099 --- /dev/null +++ b/samples/browseable/MidiScope/res/xml/scope_device_info.xml @@ -0,0 +1,24 @@ + + + + + + + diff --git a/samples/browseable/MidiScope/src/com.example.android.common.midi/EventScheduler.java b/samples/browseable/MidiScope/src/com.example.android.common.midi/EventScheduler.java new file mode 100644 index 000000000..37c0140dc --- /dev/null +++ b/samples/browseable/MidiScope/src/com.example.android.common.midi/EventScheduler.java @@ -0,0 +1,243 @@ +/* + * Copyright (C) 2015 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.example.android.common.midi; + +import java.util.SortedMap; +import java.util.TreeMap; + +/** + * Store SchedulableEvents in a timestamped buffer. + * Events may be written in any order. + * Events will be read in sorted order. + * Events with the same timestamp will be read in the order they were added. + * + * Only one Thread can write into the buffer. + * And only one Thread can read from the buffer. + */ +public class EventScheduler { + private static final long NANOS_PER_MILLI = 1000000; + + private final Object lock = new Object(); + private SortedMap mEventBuffer; + // This does not have to be guarded. It is only set by the writing thread. + // If the reader sees a null right before being set then that is OK. + private FastEventQueue mEventPool = null; + private static final int MAX_POOL_SIZE = 200; + + public EventScheduler() { + mEventBuffer = new TreeMap(); + } + + // If we keep at least one node in the list then it can be atomic + // and non-blocking. + private class FastEventQueue { + // One thread takes from the beginning of the list. + volatile SchedulableEvent mFirst; + // A second thread returns events to the end of the list. + volatile SchedulableEvent mLast; + volatile long mEventsAdded; + volatile long mEventsRemoved; + + FastEventQueue(SchedulableEvent event) { + mFirst = event; + mLast = mFirst; + mEventsAdded = 1; // Always created with one event added. Never empty. + mEventsRemoved = 0; // None removed yet. + } + + int size() { + return (int)(mEventsAdded - mEventsRemoved); + } + + /** + * Do not call this unless there is more than one event + * in the list. + * @return first event in the list + */ + public SchedulableEvent remove() { + // Take first event. + mEventsRemoved++; + SchedulableEvent event = mFirst; + mFirst = event.mNext; + return event; + } + + /** + * @param event + */ + public void add(SchedulableEvent event) { + event.mNext = null; + mLast.mNext = event; + mLast = event; + mEventsAdded++; + } + } + + /** + * Base class for events that can be stored in the EventScheduler. + */ + public static class SchedulableEvent { + private long mTimestamp; + private SchedulableEvent mNext = null; + + /** + * @param timestamp + */ + public SchedulableEvent(long timestamp) { + mTimestamp = timestamp; + } + + /** + * @return timestamp + */ + public long getTimestamp() { + return mTimestamp; + } + + /** + * The timestamp should not be modified when the event is in the + * scheduling buffer. + */ + public void setTimestamp(long timestamp) { + mTimestamp = timestamp; + } + } + + /** + * Get an event from the pool. + * Always leave at least one event in the pool. + * @return event or null + */ + public SchedulableEvent removeEventfromPool() { + SchedulableEvent event = null; + if (mEventPool != null && (mEventPool.size() > 1)) { + event = mEventPool.remove(); + } + return event; + } + + /** + * Return events to a pool so they can be reused. + * + * @param event + */ + public void addEventToPool(SchedulableEvent event) { + if (mEventPool == null) { + mEventPool = new FastEventQueue(event); + // If we already have enough items in the pool then just + // drop the event. This prevents unbounded memory leaks. + } else if (mEventPool.size() < MAX_POOL_SIZE) { + mEventPool.add(event); + } + } + + /** + * Add an event to the scheduler. Events with the same time will be + * processed in order. + * + * @param event + */ + public void add(SchedulableEvent event) { + synchronized (lock) { + FastEventQueue list = mEventBuffer.get(event.getTimestamp()); + if (list == null) { + long lowestTime = mEventBuffer.isEmpty() ? Long.MAX_VALUE + : mEventBuffer.firstKey(); + list = new FastEventQueue(event); + mEventBuffer.put(event.getTimestamp(), list); + // If the event we added is earlier than the previous earliest + // event then notify any threads waiting for the next event. + if (event.getTimestamp() < lowestTime) { + lock.notify(); + } + } else { + list.add(event); + } + } + } + + // Caller must synchronize on lock before calling. + private SchedulableEvent removeNextEventLocked(long lowestTime) { + SchedulableEvent event; + FastEventQueue list = mEventBuffer.get(lowestTime); + // Remove list from tree if this is the last node. + if ((list.size() == 1)) { + mEventBuffer.remove(lowestTime); + } + event = list.remove(); + return event; + } + + /** + * Check to see if any scheduled events are ready to be processed. + * + * @param timestamp + * @return next event or null if none ready + */ + public SchedulableEvent getNextEvent(long time) { + SchedulableEvent event = null; + synchronized (lock) { + if (!mEventBuffer.isEmpty()) { + long lowestTime = mEventBuffer.firstKey(); + // Is it time for this list to be processed? + if (lowestTime <= time) { + event = removeNextEventLocked(lowestTime); + } + } + } + // Log.i(TAG, "getNextEvent: event = " + event); + return event; + } + + /** + * Return the next available event or wait until there is an event ready to + * be processed. This method assumes that the timestamps are in nanoseconds + * and that the current time is System.nanoTime(). + * + * @return event + * @throws InterruptedException + */ + public SchedulableEvent waitNextEvent() throws InterruptedException { + SchedulableEvent event = null; + while (true) { + long millisToWait = Integer.MAX_VALUE; + synchronized (lock) { + if (!mEventBuffer.isEmpty()) { + long now = System.nanoTime(); + long lowestTime = mEventBuffer.firstKey(); + // Is it time for the earliest list to be processed? + if (lowestTime <= now) { + event = removeNextEventLocked(lowestTime); + break; + } else { + // Figure out how long to sleep until next event. + long nanosToWait = lowestTime - now; + // Add 1 millisecond so we don't wake up before it is + // ready. + millisToWait = 1 + (nanosToWait / NANOS_PER_MILLI); + // Clip 64-bit value to 32-bit max. + if (millisToWait > Integer.MAX_VALUE) { + millisToWait = Integer.MAX_VALUE; + } + } + } + lock.wait((int) millisToWait); + } + } + return event; + } +} diff --git a/samples/browseable/MidiScope/src/com.example.android.common.midi/MidiConstants.java b/samples/browseable/MidiScope/src/com.example.android.common.midi/MidiConstants.java new file mode 100644 index 000000000..38c25d505 --- /dev/null +++ b/samples/browseable/MidiScope/src/com.example.android.common.midi/MidiConstants.java @@ -0,0 +1,102 @@ +/* + * Copyright (C) 2015 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.example.android.common.midi; + +/** + * MIDI related constants and static methods. + * These values are defined in the MIDI Standard 1.0 + * available from the MIDI Manufacturers Association. + */ +public class MidiConstants { + protected final static String TAG = "MidiTools"; + public static final byte STATUS_COMMAND_MASK = (byte) 0xF0; + public static final byte STATUS_CHANNEL_MASK = (byte) 0x0F; + + // Channel voice messages. + public static final byte STATUS_NOTE_OFF = (byte) 0x80; + public static final byte STATUS_NOTE_ON = (byte) 0x90; + public static final byte STATUS_POLYPHONIC_AFTERTOUCH = (byte) 0xA0; + public static final byte STATUS_CONTROL_CHANGE = (byte) 0xB0; + public static final byte STATUS_PROGRAM_CHANGE = (byte) 0xC0; + public static final byte STATUS_CHANNEL_PRESSURE = (byte) 0xD0; + public static final byte STATUS_PITCH_BEND = (byte) 0xE0; + + // System Common Messages. + public static final byte STATUS_SYSTEM_EXCLUSIVE = (byte) 0xF0; + public static final byte STATUS_MIDI_TIME_CODE = (byte) 0xF1; + public static final byte STATUS_SONG_POSITION = (byte) 0xF2; + public static final byte STATUS_SONG_SELECT = (byte) 0xF3; + public static final byte STATUS_TUNE_REQUEST = (byte) 0xF6; + public static final byte STATUS_END_SYSEX = (byte) 0xF7; + + // System Real-Time Messages + public static final byte STATUS_TIMING_CLOCK = (byte) 0xF8; + public static final byte STATUS_START = (byte) 0xFA; + public static final byte STATUS_CONTINUE = (byte) 0xFB; + public static final byte STATUS_STOP = (byte) 0xFC; + public static final byte STATUS_ACTIVE_SENSING = (byte) 0xFE; + public static final byte STATUS_RESET = (byte) 0xFF; + + /** Number of bytes in a message nc from 8c to Ec */ + public final static int CHANNEL_BYTE_LENGTHS[] = { 3, 3, 3, 3, 2, 2, 3 }; + + /** Number of bytes in a message Fn from F0 to FF */ + public final static int SYSTEM_BYTE_LENGTHS[] = { 1, 2, 3, 2, 1, 1, 1, 1, 1, + 1, 1, 1, 1, 1, 1, 1 }; + + /** + * MIDI messages, except for SysEx, are 1,2 or 3 bytes long. + * You can tell how long a MIDI message is from the first status byte. + * Do not call this for SysEx, which has variable length. + * @param statusByte + * @return number of bytes in a complete message, zero if data byte passed + */ + public static int getBytesPerMessage(byte statusByte) { + // Java bytes are signed so we need to mask off the high bits + // to get a value between 0 and 255. + int statusInt = statusByte & 0xFF; + if (statusInt >= 0xF0) { + // System messages use low nibble for size. + return SYSTEM_BYTE_LENGTHS[statusInt & 0x0F]; + } else if(statusInt >= 0x80) { + // Channel voice messages use high nibble for size. + return CHANNEL_BYTE_LENGTHS[(statusInt >> 4) - 8]; + } else { + return 0; // data byte + } + } + + /** + * @param msg + * @param offset + * @param count + * @return true if the entire message is ActiveSensing commands + */ + public static boolean isAllActiveSensing(byte[] msg, int offset, + int count) { + // Count bytes that are not active sensing. + int goodBytes = 0; + for (int i = 0; i < count; i++) { + byte b = msg[offset + i]; + if (b != MidiConstants.STATUS_ACTIVE_SENSING) { + goodBytes++; + } + } + return (goodBytes == 0); + } + +} diff --git a/samples/browseable/MidiScope/src/com.example.android.common.midi/MidiDispatcher.java b/samples/browseable/MidiScope/src/com.example.android.common.midi/MidiDispatcher.java new file mode 100644 index 000000000..b7f1fe1e8 --- /dev/null +++ b/samples/browseable/MidiScope/src/com.example.android.common.midi/MidiDispatcher.java @@ -0,0 +1,95 @@ +/* + * Copyright (C) 2015 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.example.android.common.midi; + +import android.media.midi.MidiReceiver; +import android.media.midi.MidiSender; + +import java.io.IOException; +import java.util.concurrent.CopyOnWriteArrayList; + +/** + * Utility class for dispatching MIDI data to a list of {@link MidiReceiver}s. + * This class subclasses {@link MidiReceiver} and dispatches any data it receives + * to its receiver list. Any receivers that throw an exception upon receiving data will + * be automatically removed from the receiver list, but no IOException will be returned + * from the dispatcher's {@link MidiReceiver#onReceive} in that case. + */ +public final class MidiDispatcher extends MidiReceiver { + + private final CopyOnWriteArrayList mReceivers + = new CopyOnWriteArrayList(); + + private final MidiSender mSender = new MidiSender() { + /** + * Called to connect a {@link MidiReceiver} to the sender + * + * @param receiver the receiver to connect + */ + @Override + public void onConnect(MidiReceiver receiver) { + mReceivers.add(receiver); + } + + /** + * Called to disconnect a {@link MidiReceiver} from the sender + * + * @param receiver the receiver to disconnect + */ + @Override + public void onDisconnect(MidiReceiver receiver) { + mReceivers.remove(receiver); + } + }; + + /** + * Returns the number of {@link MidiReceiver}s this dispatcher contains. + * @return the number of receivers + */ + public int getReceiverCount() { + return mReceivers.size(); + } + + /** + * Returns a {@link MidiSender} which is used to add and remove + * {@link MidiReceiver}s + * to the dispatcher's receiver list. + * @return the dispatcher's MidiSender + */ + public MidiSender getSender() { + return mSender; + } + + @Override + public void onSend(byte[] msg, int offset, int count, long timestamp) throws IOException { + for (MidiReceiver receiver : mReceivers) { + try { + receiver.send(msg, offset, count, timestamp); + } catch (IOException e) { + // if the receiver fails we remove the receiver but do not propagate the exception + mReceivers.remove(receiver); + } + } + } + + @Override + public void flush() throws IOException { + for (MidiReceiver receiver : mReceivers) { + receiver.flush(); + } + } +} diff --git a/samples/browseable/MidiScope/src/com.example.android.common.midi/MidiEventScheduler.java b/samples/browseable/MidiScope/src/com.example.android.common.midi/MidiEventScheduler.java new file mode 100644 index 000000000..513d3939b --- /dev/null +++ b/samples/browseable/MidiScope/src/com.example.android.common.midi/MidiEventScheduler.java @@ -0,0 +1,119 @@ +/* + * Copyright (C) 2015 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.example.android.common.midi; + +import android.media.midi.MidiReceiver; + +import java.io.IOException; + +/** + * Add MIDI Events to an EventScheduler + */ +public class MidiEventScheduler extends EventScheduler { + private static final String TAG = "MidiEventScheduler"; + // Maintain a pool of scheduled events to reduce memory allocation. + // This pool increases performance by about 14%. + private final static int POOL_EVENT_SIZE = 16; + private MidiReceiver mReceiver = new SchedulingReceiver(); + + private class SchedulingReceiver extends MidiReceiver + { + /** + * Store these bytes in the EventScheduler to be delivered at the specified + * time. + */ + @Override + public void onSend(byte[] msg, int offset, int count, long timestamp) + throws IOException { + MidiEvent event = createScheduledEvent(msg, offset, count, timestamp); + if (event != null) { + add(event); + } + } + } + + public static class MidiEvent extends SchedulableEvent { + public int count = 0; + public byte[] data; + + private MidiEvent(int count) { + super(0); + data = new byte[count]; + } + + private MidiEvent(byte[] msg, int offset, int count, long timestamp) { + super(timestamp); + data = new byte[count]; + System.arraycopy(msg, offset, data, 0, count); + this.count = count; + } + + @Override + public String toString() { + String text = "Event: "; + for (int i = 0; i < count; i++) { + text += data[i] + ", "; + } + return text; + } + } + + /** + * Create an event that contains the message. + */ + private MidiEvent createScheduledEvent(byte[] msg, int offset, int count, + long timestamp) { + MidiEvent event; + if (count > POOL_EVENT_SIZE) { + event = new MidiEvent(msg, offset, count, timestamp); + } else { + event = (MidiEvent) removeEventfromPool(); + if (event == null) { + event = new MidiEvent(POOL_EVENT_SIZE); + } + System.arraycopy(msg, offset, event.data, 0, count); + event.count = count; + event.setTimestamp(timestamp); + } + return event; + } + + /** + * Return events to a pool so they can be reused. + * + * @param event + */ + @Override + public void addEventToPool(SchedulableEvent event) { + // Make sure the event is suitable for the pool. + if (event instanceof MidiEvent) { + MidiEvent midiEvent = (MidiEvent) event; + if (midiEvent.data.length == POOL_EVENT_SIZE) { + super.addEventToPool(event); + } + } + } + + /** + * This MidiReceiver will write date to the scheduling buffer. + * @return the MidiReceiver + */ + public MidiReceiver getReceiver() { + return mReceiver; + } + +} diff --git a/samples/browseable/MidiScope/src/com.example.android.common.midi/MidiEventThread.java b/samples/browseable/MidiScope/src/com.example.android.common.midi/MidiEventThread.java new file mode 100644 index 000000000..626e83cf0 --- /dev/null +++ b/samples/browseable/MidiScope/src/com.example.android.common.midi/MidiEventThread.java @@ -0,0 +1,90 @@ +/* + * Copyright (C) 2015 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.example.android.common.midi; + +import android.media.midi.MidiSender; +import android.util.Log; + +import java.io.IOException; + +public class MidiEventThread extends MidiEventScheduler { + protected static final String TAG = "MidiEventThread"; + + private EventThread mEventThread; + MidiDispatcher mDispatcher = new MidiDispatcher(); + + class EventThread extends Thread { + private boolean go = true; + + @Override + public void run() { + while (go) { + try { + MidiEvent event = (MidiEvent) waitNextEvent(); + try { + Log.i(TAG, "Fire event " + event.data[0] + " at " + + event.getTimestamp()); + mDispatcher.send(event.data, 0, + event.count, event.getTimestamp()); + } catch (IOException e) { + e.printStackTrace(); + } + // Put event back in the pool for future use. + addEventToPool(event); + } catch (InterruptedException e) { + // OK, this is how we stop the thread. + } + } + } + + /** + * Asynchronously tell the thread to stop. + */ + public void requestStop() { + go = false; + interrupt(); + } + } + + public void start() { + stop(); + mEventThread = new EventThread(); + mEventThread.start(); + } + + /** + * Asks the thread to stop then waits for it to stop. + */ + public void stop() { + if (mEventThread != null) { + mEventThread.requestStop(); + try { + mEventThread.join(500); + } catch (InterruptedException e) { + Log.e(TAG, + "Interrupted while waiting for MIDI EventScheduler thread to stop."); + } finally { + mEventThread = null; + } + } + } + + public MidiSender getSender() { + return mDispatcher.getSender(); + } + +} diff --git a/samples/browseable/MidiScope/src/com.example.android.common.midi/MidiFramer.java b/samples/browseable/MidiScope/src/com.example.android.common.midi/MidiFramer.java new file mode 100644 index 000000000..c274925ac --- /dev/null +++ b/samples/browseable/MidiScope/src/com.example.android.common.midi/MidiFramer.java @@ -0,0 +1,112 @@ +/* + * Copyright (C) 2015 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.example.android.common.midi; + +import android.media.midi.MidiReceiver; +import android.util.Log; + +import java.io.IOException; + +/** + * Convert stream of arbitrary MIDI bytes into discrete messages. + * + * Parses the incoming bytes and then posts individual messages to the receiver + * specified in the constructor. Short messages of 1-3 bytes will be complete. + * System Exclusive messages may be posted in pieces. + * + * Resolves Running Status and interleaved System Real-Time messages. + */ +public class MidiFramer extends MidiReceiver { + private MidiReceiver mReceiver; + private byte[] mBuffer = new byte[3]; + private int mCount; + private byte mRunningStatus; + private int mNeeded; + private boolean mInSysEx; + + public MidiFramer(MidiReceiver receiver) { + mReceiver = receiver; + } + + /* + * @see android.midi.MidiReceiver#onSend(byte[], int, int, long) + */ + @Override + public void onSend(byte[] data, int offset, int count, long timestamp) + throws IOException { + int sysExStartOffset = (mInSysEx ? offset : -1); + + for (int i = 0; i < count; i++) { + final byte currentByte = data[offset]; + final int currentInt = currentByte & 0xFF; + if (currentInt >= 0x80) { // status byte? + if (currentInt < 0xF0) { // channel message? + mRunningStatus = currentByte; + mCount = 1; + mNeeded = MidiConstants.getBytesPerMessage(currentByte) - 1; + } else if (currentInt < 0xF8) { // system common? + if (currentInt == 0xF0 /* SysEx Start */) { + // Log.i(TAG, "SysEx Start"); + mInSysEx = true; + sysExStartOffset = offset; + } else if (currentInt == 0xF7 /* SysEx End */) { + // Log.i(TAG, "SysEx End"); + if (mInSysEx) { + mReceiver.send(data, sysExStartOffset, + offset - sysExStartOffset + 1, timestamp); + mInSysEx = false; + sysExStartOffset = -1; + } + } else { + mBuffer[0] = currentByte; + mRunningStatus = 0; + mCount = 1; + mNeeded = MidiConstants.getBytesPerMessage(currentByte) - 1; + } + } else { // real-time? + // Single byte message interleaved with other data. + if (mInSysEx) { + mReceiver.send(data, sysExStartOffset, + offset - sysExStartOffset, timestamp); + sysExStartOffset = offset + 1; + } + mReceiver.send(data, offset, 1, timestamp); + } + } else { // data byte + if (!mInSysEx) { + mBuffer[mCount++] = currentByte; + if (--mNeeded == 0) { + if (mRunningStatus != 0) { + mBuffer[0] = mRunningStatus; + } + mReceiver.send(mBuffer, 0, mCount, timestamp); + mNeeded = MidiConstants.getBytesPerMessage(mBuffer[0]) - 1; + mCount = 1; + } + } + } + ++offset; + } + + // send any accumulatedSysEx data + if (sysExStartOffset >= 0 && sysExStartOffset < offset) { + mReceiver.send(data, sysExStartOffset, + offset - sysExStartOffset, timestamp); + } + } + +} diff --git a/samples/browseable/MidiScope/src/com.example.android.common.midi/MidiInputPortSelector.java b/samples/browseable/MidiScope/src/com.example.android.common.midi/MidiInputPortSelector.java new file mode 100644 index 000000000..7c665bace --- /dev/null +++ b/samples/browseable/MidiScope/src/com.example.android.common.midi/MidiInputPortSelector.java @@ -0,0 +1,93 @@ +/* + * Copyright (C) 2015 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.example.android.common.midi; + +import android.app.Activity; +import android.media.midi.MidiDevice; +import android.media.midi.MidiDeviceInfo; +import android.media.midi.MidiInputPort; +import android.media.midi.MidiManager; +import android.media.midi.MidiReceiver; +import android.os.Handler; +import android.os.Looper; +import android.util.Log; + +import java.io.IOException; + +/** + * Manages a Spinner for selecting a MidiInputPort. + */ +public class MidiInputPortSelector extends MidiPortSelector { + + private MidiInputPort mInputPort; + private MidiDevice mOpenDevice; + + /** + * @param midiManager + * @param activity + * @param spinnerId ID from the layout resource + */ + public MidiInputPortSelector(MidiManager midiManager, Activity activity, + int spinnerId) { + super(midiManager, activity, spinnerId, MidiDeviceInfo.PortInfo.TYPE_INPUT); + } + + @Override + public void onPortSelected(final MidiPortWrapper wrapper) { + close(); + final MidiDeviceInfo info = wrapper.getDeviceInfo(); + if (info != null) { + mMidiManager.openDevice(info, new MidiManager.OnDeviceOpenedListener() { + @Override + public void onDeviceOpened(MidiDevice device) { + if (device == null) { + Log.e(MidiConstants.TAG, "could not open " + info); + } else { + mOpenDevice = device; + mInputPort = mOpenDevice.openInputPort( + wrapper.getPortIndex()); + if (mInputPort == null) { + Log.e(MidiConstants.TAG, "could not open input port on " + info); + } + } + } + }, null); + // Don't run the callback on the UI thread because openInputPort might take a while. + } + } + + public MidiReceiver getReceiver() { + return mInputPort; + } + + @Override + public void onClose() { + try { + if (mInputPort != null) { + Log.i(MidiConstants.TAG, "MidiInputPortSelector.onClose() - close port"); + mInputPort.close(); + } + mInputPort = null; + if (mOpenDevice != null) { + mOpenDevice.close(); + } + mOpenDevice = null; + } catch (IOException e) { + Log.e(MidiConstants.TAG, "cleanup failed", e); + } + } +} diff --git a/samples/browseable/MidiScope/src/com.example.android.common.midi/MidiOutputPortConnectionSelector.java b/samples/browseable/MidiScope/src/com.example.android.common.midi/MidiOutputPortConnectionSelector.java new file mode 100644 index 000000000..ca1ade48c --- /dev/null +++ b/samples/browseable/MidiScope/src/com.example.android.common.midi/MidiOutputPortConnectionSelector.java @@ -0,0 +1,84 @@ +/* + * Copyright (C) 2015 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.example.android.common.midi; + +import android.app.Activity; +import android.media.midi.MidiDeviceInfo; +import android.media.midi.MidiManager; +import android.util.Log; + +import java.io.IOException; + +/** + * Select an output port and connect it to a destination input port. + */ +public class MidiOutputPortConnectionSelector extends MidiPortSelector { + + private MidiPortConnector mSynthConnector; + private MidiDeviceInfo mDestinationDeviceInfo; + private int mDestinationPortIndex; + private MidiPortConnector.OnPortsConnectedListener mConnectedListener; + + /** + * @param midiManager + * @param activity + * @param spinnerId + * @param type + */ + public MidiOutputPortConnectionSelector(MidiManager midiManager, + Activity activity, int spinnerId, + MidiDeviceInfo destinationDeviceInfo, int destinationPortIndex) { + super(midiManager, activity, spinnerId, + MidiDeviceInfo.PortInfo.TYPE_OUTPUT); + mDestinationDeviceInfo = destinationDeviceInfo; + mDestinationPortIndex = destinationPortIndex; + } + + @Override + public void onPortSelected(final MidiPortWrapper wrapper) { + Log.i(MidiConstants.TAG, "connectPortToSynth: " + wrapper); + onClose(); + if (wrapper.getDeviceInfo() != null) { + mSynthConnector = new MidiPortConnector(mMidiManager); + mSynthConnector.connectToDevicePort(wrapper.getDeviceInfo(), + wrapper.getPortIndex(), mDestinationDeviceInfo, + mDestinationPortIndex, + // not safe on UI thread + mConnectedListener, null); + } + } + + @Override + public void onClose() { + try { + if (mSynthConnector != null) { + mSynthConnector.close(); + mSynthConnector = null; + } + } catch (IOException e) { + Log.e(MidiConstants.TAG, "Exception in closeSynthResources()", e); + } + } + + /** + * @param myPortsConnectedListener + */ + public void setConnectedListener( + MidiPortConnector.OnPortsConnectedListener connectedListener) { + mConnectedListener = connectedListener; + } +} diff --git a/samples/browseable/MidiScope/src/com.example.android.common.midi/MidiOutputPortSelector.java b/samples/browseable/MidiScope/src/com.example.android.common.midi/MidiOutputPortSelector.java new file mode 100644 index 000000000..5aebf727e --- /dev/null +++ b/samples/browseable/MidiScope/src/com.example.android.common.midi/MidiOutputPortSelector.java @@ -0,0 +1,103 @@ +/* + * Copyright (C) 2015 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.example.android.common.midi; + +import android.app.Activity; +import android.media.midi.MidiDevice; +import android.media.midi.MidiDeviceInfo; +import android.media.midi.MidiManager; +import android.media.midi.MidiOutputPort; +import android.media.midi.MidiSender; +import android.os.Handler; +import android.os.Looper; +import android.util.Log; + +import java.io.IOException; + +/** + * Manages a Spinner for selecting a MidiOutputPort. + */ +public class MidiOutputPortSelector extends MidiPortSelector { + private MidiOutputPort mOutputPort; + private MidiDispatcher mDispatcher = new MidiDispatcher(); + private MidiDevice mOpenDevice; + + /** + * @param midiManager + * @param activity + * @param spinnerId ID from the layout resource + */ + public MidiOutputPortSelector(MidiManager midiManager, Activity activity, + int spinnerId) { + super(midiManager, activity, spinnerId, MidiDeviceInfo.PortInfo.TYPE_OUTPUT); + } + + @Override + public void onPortSelected(final MidiPortWrapper wrapper) { + Log.i(MidiConstants.TAG, "onPortSelected: " + wrapper); + close(); + + final MidiDeviceInfo info = wrapper.getDeviceInfo(); + if (info != null) { + mMidiManager.openDevice(info, new MidiManager.OnDeviceOpenedListener() { + + @Override + public void onDeviceOpened(MidiDevice device) { + if (device == null) { + Log.e(MidiConstants.TAG, "could not open " + info); + } else { + mOpenDevice = device; + mOutputPort = device.openOutputPort(wrapper.getPortIndex()); + if (mOutputPort == null) { + Log.e(MidiConstants.TAG, + "could not open output port for " + info); + return; + } + mOutputPort.connect(mDispatcher); + } + } + }, null); + // Don't run the callback on the UI thread because openOutputPort might take a while. + } + } + + @Override + public void onClose() { + try { + if (mOutputPort != null) { + mOutputPort.disconnect(mDispatcher); + } + mOutputPort = null; + if (mOpenDevice != null) { + mOpenDevice.close(); + } + mOpenDevice = null; + } catch (IOException e) { + Log.e(MidiConstants.TAG, "cleanup failed", e); + } + } + + /** + * You can connect your MidiReceivers to this sender. The user will then select which output + * port will send messages through this MidiSender. + * @return a MidiSender that will send the messages from the selected port. + */ + public MidiSender getSender() { + return mDispatcher.getSender(); + } + +} diff --git a/samples/browseable/MidiScope/src/com.example.android.common.midi/MidiPortConnector.java b/samples/browseable/MidiScope/src/com.example.android.common.midi/MidiPortConnector.java new file mode 100644 index 000000000..457494d1b --- /dev/null +++ b/samples/browseable/MidiScope/src/com.example.android.common.midi/MidiPortConnector.java @@ -0,0 +1,200 @@ +/* + * Copyright (C) 2015 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.example.android.common.midi; + +import android.media.midi.MidiDevice; +import android.media.midi.MidiDevice.MidiConnection; +import android.media.midi.MidiDeviceInfo; +import android.media.midi.MidiInputPort; +import android.media.midi.MidiManager; +import android.os.Handler; +import android.util.Log; + +import java.io.IOException; + +/** + * Tool for connecting MIDI ports on two remote devices. + */ +public class MidiPortConnector { + private final MidiManager mMidiManager; + private MidiDevice mSourceDevice; + private MidiDevice mDestinationDevice; + private MidiConnection mConnection; + + /** + * @param mMidiManager + */ + public MidiPortConnector(MidiManager midiManager) { + mMidiManager = midiManager; + } + + public void close() throws IOException { + if (mConnection != null) { + Log.i(MidiConstants.TAG, + "MidiPortConnector closing connection " + mConnection); + mConnection.close(); + mConnection = null; + } + if (mSourceDevice != null) { + mSourceDevice.close(); + mSourceDevice = null; + } + if (mDestinationDevice != null) { + mDestinationDevice.close(); + mDestinationDevice = null; + } + } + + private void safeClose() { + try { + close(); + } catch (IOException e) { + Log.e(MidiConstants.TAG, "could not close resources", e); + } + } + + /** + * Listener class used for receiving the results of + * {@link #connectToDevicePort} + */ + public interface OnPortsConnectedListener { + /** + * Called to respond to a {@link #connectToDevicePort} request + * + * @param connection + * a {@link MidiConnection} that represents the connected + * ports, or null if connection failed + */ + abstract public void onPortsConnected(MidiConnection connection); + } + + /** + * Open two devices and connect their ports. + * + * @param sourceDeviceInfo + * @param sourcePortIndex + * @param destinationDeviceInfo + * @param destinationPortIndex + */ + public void connectToDevicePort(final MidiDeviceInfo sourceDeviceInfo, + final int sourcePortIndex, + final MidiDeviceInfo destinationDeviceInfo, + final int destinationPortIndex) { + connectToDevicePort(sourceDeviceInfo, sourcePortIndex, + destinationDeviceInfo, destinationPortIndex, null, null); + } + + /** + * Open two devices and connect their ports. + * + * @param sourceDeviceInfo + * @param sourcePortIndex + * @param destinationDeviceInfo + * @param destinationPortIndex + */ + public void connectToDevicePort(final MidiDeviceInfo sourceDeviceInfo, + final int sourcePortIndex, + final MidiDeviceInfo destinationDeviceInfo, + final int destinationPortIndex, + final OnPortsConnectedListener listener, final Handler handler) { + safeClose(); + mMidiManager.openDevice(destinationDeviceInfo, + new MidiManager.OnDeviceOpenedListener() { + @Override + public void onDeviceOpened(MidiDevice destinationDevice) { + if (destinationDevice == null) { + Log.e(MidiConstants.TAG, + "could not open " + destinationDeviceInfo); + if (listener != null) { + listener.onPortsConnected(null); + } + } else { + mDestinationDevice = destinationDevice; + Log.i(MidiConstants.TAG, + "connectToDevicePort opened " + + destinationDeviceInfo); + // Destination device was opened so go to next step. + MidiInputPort destinationInputPort = destinationDevice + .openInputPort(destinationPortIndex); + if (destinationInputPort != null) { + Log.i(MidiConstants.TAG, + "connectToDevicePort opened port on " + + destinationDeviceInfo); + connectToDevicePort(sourceDeviceInfo, + sourcePortIndex, + destinationInputPort, + listener, handler); + } else { + Log.e(MidiConstants.TAG, + "could not open port on " + + destinationDeviceInfo); + safeClose(); + if (listener != null) { + listener.onPortsConnected(null); + } + } + } + } + }, handler); + } + + + /** + * Open a source device and connect its output port to the + * destinationInputPort. + * + * @param sourceDeviceInfo + * @param sourcePortIndex + * @param destinationInputPort + */ + private void connectToDevicePort(final MidiDeviceInfo sourceDeviceInfo, + final int sourcePortIndex, + final MidiInputPort destinationInputPort, + final OnPortsConnectedListener listener, final Handler handler) { + mMidiManager.openDevice(sourceDeviceInfo, + new MidiManager.OnDeviceOpenedListener() { + @Override + public void onDeviceOpened(MidiDevice device) { + if (device == null) { + Log.e(MidiConstants.TAG, + "could not open " + sourceDeviceInfo); + safeClose(); + if (listener != null) { + listener.onPortsConnected(null); + } + } else { + Log.i(MidiConstants.TAG, + "connectToDevicePort opened " + + sourceDeviceInfo); + // Device was opened so connect the ports. + mSourceDevice = device; + mConnection = device.connectPorts( + destinationInputPort, sourcePortIndex); + if (mConnection == null) { + Log.e(MidiConstants.TAG, "could not connect to " + + sourceDeviceInfo); + safeClose(); + } + if (listener != null) { + listener.onPortsConnected(mConnection); + } + } + } + }, handler); + } + +} diff --git a/samples/browseable/MidiScope/src/com.example.android.common.midi/MidiPortSelector.java b/samples/browseable/MidiScope/src/com.example.android.common.midi/MidiPortSelector.java new file mode 100644 index 000000000..39f983e38 --- /dev/null +++ b/samples/browseable/MidiScope/src/com.example.android.common.midi/MidiPortSelector.java @@ -0,0 +1,183 @@ +/* + * Copyright (C) 2015 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.example.android.common.midi; + +import android.app.Activity; +import android.media.midi.MidiDeviceInfo; +import android.media.midi.MidiDeviceStatus; +import android.media.midi.MidiManager; +import android.media.midi.MidiManager.DeviceCallback; +import android.os.Handler; +import android.os.Looper; +import android.util.Log; +import android.view.View; +import android.widget.AdapterView; +import android.widget.ArrayAdapter; +import android.widget.Spinner; + +import java.util.HashSet; + +/** + * Base class that uses a Spinner to select available MIDI ports. + */ +public abstract class MidiPortSelector extends DeviceCallback { + private int mType = MidiDeviceInfo.PortInfo.TYPE_INPUT; + protected ArrayAdapter mAdapter; + protected HashSet mBusyPorts = new HashSet(); + private Spinner mSpinner; + protected MidiManager mMidiManager; + protected Activity mActivity; + private MidiPortWrapper mCurrentWrapper; + + /** + * @param midiManager + * @param activity + * @param spinnerId + * ID from the layout resource + * @param type + * TYPE_INPUT or TYPE_OUTPUT + */ + public MidiPortSelector(MidiManager midiManager, Activity activity, + int spinnerId, int type) { + mMidiManager = midiManager; + mActivity = activity; + mType = type; + mAdapter = new ArrayAdapter(activity, + android.R.layout.simple_spinner_item); + mAdapter.setDropDownViewResource( + android.R.layout.simple_spinner_dropdown_item); + mAdapter.add(new MidiPortWrapper(null, 0, 0)); + + mSpinner = (Spinner) activity.findViewById(spinnerId); + mSpinner.setOnItemSelectedListener( + new AdapterView.OnItemSelectedListener() { + + public void onItemSelected(AdapterView parent, View view, + int pos, long id) { + mCurrentWrapper = mAdapter.getItem(pos); + onPortSelected(mCurrentWrapper); + } + + public void onNothingSelected(AdapterView parent) { + onPortSelected(null); + mCurrentWrapper = null; + } + }); + mSpinner.setAdapter(mAdapter); + + mMidiManager.registerDeviceCallback(this, + new Handler(Looper.getMainLooper())); + + MidiDeviceInfo[] infos = mMidiManager.getDevices(); + for (MidiDeviceInfo info : infos) { + onDeviceAdded(info); + } + } + + /** + * Set to no port selected. + */ + public void clearSelection() { + mSpinner.setSelection(0); + } + + private int getInfoPortCount(final MidiDeviceInfo info) { + int portCount = (mType == MidiDeviceInfo.PortInfo.TYPE_INPUT) + ? info.getInputPortCount() : info.getOutputPortCount(); + return portCount; + } + + @Override + public void onDeviceAdded(final MidiDeviceInfo info) { + int portCount = getInfoPortCount(info); + for (int i = 0; i < portCount; ++i) { + MidiPortWrapper wrapper = new MidiPortWrapper(info, mType, i); + mAdapter.add(wrapper); + Log.i(MidiConstants.TAG, wrapper + " was added"); + mAdapter.notifyDataSetChanged(); + } + } + + @Override + public void onDeviceRemoved(final MidiDeviceInfo info) { + int portCount = getInfoPortCount(info); + for (int i = 0; i < portCount; ++i) { + MidiPortWrapper wrapper = new MidiPortWrapper(info, mType, i); + MidiPortWrapper currentWrapper = mCurrentWrapper; + mAdapter.remove(wrapper); + // If the currently selected port was removed then select no port. + if (wrapper.equals(currentWrapper)) { + clearSelection(); + } + mAdapter.notifyDataSetChanged(); + Log.i(MidiConstants.TAG, wrapper + " was removed"); + } + } + + @Override + public void onDeviceStatusChanged(final MidiDeviceStatus status) { + // If an input port becomes busy then remove it from the menu. + // If it becomes free then add it back to the menu. + if (mType == MidiDeviceInfo.PortInfo.TYPE_INPUT) { + MidiDeviceInfo info = status.getDeviceInfo(); + Log.i(MidiConstants.TAG, "MidiPortSelector.onDeviceStatusChanged status = " + status + + ", mType = " + mType + + ", activity = " + mActivity.getPackageName() + + ", info = " + info); + // Look for transitions from free to busy. + int portCount = info.getInputPortCount(); + for (int i = 0; i < portCount; ++i) { + MidiPortWrapper wrapper = new MidiPortWrapper(info, mType, i); + if (!wrapper.equals(mCurrentWrapper)) { + if (status.isInputPortOpen(i)) { // busy? + if (!mBusyPorts.contains(wrapper)) { + // was free, now busy + mBusyPorts.add(wrapper); + mAdapter.remove(wrapper); + mAdapter.notifyDataSetChanged(); + } + } else { + if (mBusyPorts.remove(wrapper)) { + // was busy, now free + mAdapter.add(wrapper); + mAdapter.notifyDataSetChanged(); + } + } + } + } + } + } + + /** + * Implement this method to handle the user selecting a port on a device. + * + * @param wrapper + */ + public abstract void onPortSelected(MidiPortWrapper wrapper); + + /** + * Implement this method to clean up any open resources. + */ + public abstract void onClose(); + + /** + * + */ + public void close() { + onClose(); + } +} diff --git a/samples/browseable/MidiScope/src/com.example.android.common.midi/MidiPortWrapper.java b/samples/browseable/MidiScope/src/com.example.android.common.midi/MidiPortWrapper.java new file mode 100644 index 000000000..77aa73458 --- /dev/null +++ b/samples/browseable/MidiScope/src/com.example.android.common.midi/MidiPortWrapper.java @@ -0,0 +1,125 @@ +/* + * Copyright (C) 2015 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.example.android.common.midi; + +import android.media.midi.MidiDeviceInfo; +import android.media.midi.MidiDeviceInfo.PortInfo; +import android.util.Log; + +// Wrapper for a MIDI device and port description. +public class MidiPortWrapper { + private MidiDeviceInfo mInfo; + private int mPortIndex; + private int mType; + private String mString; + + /** + * Wrapper for a MIDI device and port description. + * @param info + * @param portType + * @param portIndex + */ + public MidiPortWrapper(MidiDeviceInfo info, int portType, int portIndex) { + mInfo = info; + mType = portType; + mPortIndex = portIndex; + } + + private void updateString() { + if (mInfo == null) { + mString = "- - - - - -"; + } else { + StringBuilder sb = new StringBuilder(); + String name = mInfo.getProperties() + .getString(MidiDeviceInfo.PROPERTY_NAME); + if (name == null) { + name = mInfo.getProperties() + .getString(MidiDeviceInfo.PROPERTY_MANUFACTURER) + ", " + + mInfo.getProperties() + .getString(MidiDeviceInfo.PROPERTY_PRODUCT); + } + sb.append("#" + mInfo.getId()); + sb.append(", ").append(name); + PortInfo portInfo = findPortInfo(); + sb.append("[" + mPortIndex + "]"); + if (portInfo != null) { + sb.append(", ").append(portInfo.getName()); + } else { + sb.append(", null"); + } + mString = sb.toString(); + } + } + + /** + * @param info + * @param portIndex + * @return + */ + private PortInfo findPortInfo() { + PortInfo[] ports = mInfo.getPorts(); + for (PortInfo portInfo : ports) { + if (portInfo.getPortNumber() == mPortIndex + && portInfo.getType() == mType) { + return portInfo; + } + } + return null; + } + + public int getPortIndex() { + return mPortIndex; + } + + public MidiDeviceInfo getDeviceInfo() { + return mInfo; + } + + @Override + public String toString() { + if (mString == null) { + updateString(); + } + return mString; + } + + @Override + public boolean equals(Object other) { + if (other == null) + return false; + if (!(other instanceof MidiPortWrapper)) + return false; + MidiPortWrapper otherWrapper = (MidiPortWrapper) other; + if (mPortIndex != otherWrapper.mPortIndex) + return false; + if (mType != otherWrapper.mType) + return false; + if (mInfo == null) + return (otherWrapper.mInfo == null); + return mInfo.equals(otherWrapper.mInfo); + } + + @Override + public int hashCode() { + int hashCode = 1; + hashCode = 31 * hashCode + mPortIndex; + hashCode = 31 * hashCode + mType; + hashCode = 31 * hashCode + mInfo.hashCode(); + return hashCode; + } + +} diff --git a/samples/browseable/MidiScope/src/com.example.android.common.midi/MidiTools.java b/samples/browseable/MidiScope/src/com.example.android.common.midi/MidiTools.java new file mode 100644 index 000000000..82e3de4ba --- /dev/null +++ b/samples/browseable/MidiScope/src/com.example.android.common.midi/MidiTools.java @@ -0,0 +1,46 @@ +/* + * Copyright (C) 2015 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.example.android.common.midi; + +import android.media.midi.MidiDeviceInfo; +import android.media.midi.MidiManager; + +/** + * Miscellaneous tools for Android MIDI. + */ +public class MidiTools { + + /** + * @return a device that matches the manufacturer and product or null + */ + public static MidiDeviceInfo findDevice(MidiManager midiManager, + String manufacturer, String product) { + for (MidiDeviceInfo info : midiManager.getDevices()) { + String deviceManufacturer = info.getProperties() + .getString(MidiDeviceInfo.PROPERTY_MANUFACTURER); + if ((manufacturer != null) + && manufacturer.equals(deviceManufacturer)) { + String deviceProduct = info.getProperties() + .getString(MidiDeviceInfo.PROPERTY_PRODUCT); + if ((product != null) && product.equals(deviceProduct)) { + return info; + } + } + } + return null; + } +} diff --git a/samples/browseable/MidiScope/src/com.example.android.common.midi/synth/EnvelopeADSR.java b/samples/browseable/MidiScope/src/com.example.android.common.midi/synth/EnvelopeADSR.java new file mode 100644 index 000000000..a29a1933e --- /dev/null +++ b/samples/browseable/MidiScope/src/com.example.android.common.midi/synth/EnvelopeADSR.java @@ -0,0 +1,111 @@ +/* + * Copyright (C) 2015 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.example.android.common.midi.synth; + +/** + * Very simple Attack, Decay, Sustain, Release envelope with linear ramps. + * + * Times are in seconds. + */ +public class EnvelopeADSR extends SynthUnit { + private static final int IDLE = 0; + private static final int ATTACK = 1; + private static final int DECAY = 2; + private static final int SUSTAIN = 3; + private static final int RELEASE = 4; + private static final int FINISHED = 5; + private static final float MIN_TIME = 0.001f; + + private float mAttackRate; + private float mRreleaseRate; + private float mSustainLevel; + private float mDecayRate; + private float mCurrent; + private int mSstate = IDLE; + + public EnvelopeADSR() { + setAttackTime(0.003f); + setDecayTime(0.08f); + setSustainLevel(0.3f); + setReleaseTime(1.0f); + } + + public void setAttackTime(float time) { + if (time < MIN_TIME) + time = MIN_TIME; + mAttackRate = 1.0f / (SynthEngine.FRAME_RATE * time); + } + + public void setDecayTime(float time) { + if (time < MIN_TIME) + time = MIN_TIME; + mDecayRate = 1.0f / (SynthEngine.FRAME_RATE * time); + } + + public void setSustainLevel(float level) { + if (level < 0.0f) + level = 0.0f; + mSustainLevel = level; + } + + public void setReleaseTime(float time) { + if (time < MIN_TIME) + time = MIN_TIME; + mRreleaseRate = 1.0f / (SynthEngine.FRAME_RATE * time); + } + + public void on() { + mSstate = ATTACK; + } + + public void off() { + mSstate = RELEASE; + } + + @Override + public float render() { + switch (mSstate) { + case ATTACK: + mCurrent += mAttackRate; + if (mCurrent > 1.0f) { + mCurrent = 1.0f; + mSstate = DECAY; + } + break; + case DECAY: + mCurrent -= mDecayRate; + if (mCurrent < mSustainLevel) { + mCurrent = mSustainLevel; + mSstate = SUSTAIN; + } + break; + case RELEASE: + mCurrent -= mRreleaseRate; + if (mCurrent < 0.0f) { + mCurrent = 0.0f; + mSstate = FINISHED; + } + break; + } + return mCurrent; + } + + public boolean isDone() { + return mSstate == FINISHED; + } + +} diff --git a/samples/browseable/MidiScope/src/com.example.android.common.midi/synth/SawOscillator.java b/samples/browseable/MidiScope/src/com.example.android.common.midi/synth/SawOscillator.java new file mode 100644 index 000000000..c02a6a1a5 --- /dev/null +++ b/samples/browseable/MidiScope/src/com.example.android.common.midi/synth/SawOscillator.java @@ -0,0 +1,73 @@ +/* + * Copyright (C) 2015 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.example.android.common.midi.synth; + +public class SawOscillator extends SynthUnit { + private float mPhase = 0.0f; + private float mPhaseIncrement = 0.01f; + private float mFrequency = 0.0f; + private float mFrequencyScaler = 1.0f; + private float mAmplitude = 1.0f; + + public void setPitch(float pitch) { + float freq = (float) pitchToFrequency(pitch); + setFrequency(freq); + } + + public void setFrequency(float frequency) { + mFrequency = frequency; + updatePhaseIncrement(); + } + + private void updatePhaseIncrement() { + mPhaseIncrement = 2.0f * mFrequency * mFrequencyScaler / 48000.0f; + } + + public void setAmplitude(float amplitude) { + mAmplitude = amplitude; + } + + public float getAmplitude() { + return mAmplitude; + } + + public float getFrequencyScaler() { + return mFrequencyScaler; + } + + public void setFrequencyScaler(float frequencyScaler) { + mFrequencyScaler = frequencyScaler; + updatePhaseIncrement(); + } + + float incrementWrapPhase() { + mPhase += mPhaseIncrement; + while (mPhase > 1.0) { + mPhase -= 2.0; + } + while (mPhase < -1.0) { + mPhase += 2.0; + } + return mPhase; + } + + @Override + public float render() { + return incrementWrapPhase() * mAmplitude; + } + +} diff --git a/samples/browseable/MidiScope/src/com.example.android.common.midi/synth/SawOscillatorDPW.java b/samples/browseable/MidiScope/src/com.example.android.common.midi/synth/SawOscillatorDPW.java new file mode 100644 index 000000000..e5d661d56 --- /dev/null +++ b/samples/browseable/MidiScope/src/com.example.android.common.midi/synth/SawOscillatorDPW.java @@ -0,0 +1,52 @@ +/* + * Copyright (C) 2015 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.example.android.common.midi.synth; + +/** + * Band limited sawtooth oscillator. + * This will have very little aliasing at high frequencies. + */ +public class SawOscillatorDPW extends SawOscillator { + private float mZ1 = 0.0f; // delayed values + private float mZ2 = 0.0f; + private float mScaler; // frequency dependent scaler + private final static float VERY_LOW_FREQ = 0.0000001f; + + @Override + public void setFrequency(float freq) { + /* Calculate scaling based on frequency. */ + freq = Math.abs(freq); + super.setFrequency(freq); + if (freq < VERY_LOW_FREQ) { + mScaler = (float) (0.125 * 44100 / VERY_LOW_FREQ); + } else { + mScaler = (float) (0.125 * 44100 / freq); + } + } + + @Override + public float render() { + float phase = incrementWrapPhase(); + /* Square the raw sawtooth. */ + float squared = phase * phase; + float diffed = squared - mZ2; + mZ2 = mZ1; + mZ1 = squared; + return diffed * mScaler * getAmplitude(); + } + +} diff --git a/samples/browseable/MidiScope/src/com.example.android.common.midi/synth/SawVoice.java b/samples/browseable/MidiScope/src/com.example.android.common.midi/synth/SawVoice.java new file mode 100644 index 000000000..3b3e543e8 --- /dev/null +++ b/samples/browseable/MidiScope/src/com.example.android.common.midi/synth/SawVoice.java @@ -0,0 +1,65 @@ +/* + * Copyright (C) 2015 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.example.android.common.midi.synth; + +/** + * Sawtooth oscillator with an ADSR. + */ +public class SawVoice extends SynthVoice { + private SawOscillator mOscillator; + private EnvelopeADSR mEnvelope; + + public SawVoice() { + mOscillator = createOscillator(); + mEnvelope = new EnvelopeADSR(); + } + + protected SawOscillator createOscillator() { + return new SawOscillator(); + } + + @Override + public void noteOn(int noteIndex, int velocity) { + super.noteOn(noteIndex, velocity); + mOscillator.setPitch(noteIndex); + mOscillator.setAmplitude(getAmplitude()); + mEnvelope.on(); + } + + @Override + public void noteOff() { + super.noteOff(); + mEnvelope.off(); + } + + @Override + public void setFrequencyScaler(float scaler) { + mOscillator.setFrequencyScaler(scaler); + } + + @Override + public float render() { + float output = mOscillator.render() * mEnvelope.render(); + return output; + } + + @Override + public boolean isDone() { + return mEnvelope.isDone(); + } + +} diff --git a/samples/browseable/MidiScope/src/com.example.android.common.midi/synth/SimpleAudioOutput.java b/samples/browseable/MidiScope/src/com.example.android.common.midi/synth/SimpleAudioOutput.java new file mode 100644 index 000000000..04aa19c0b --- /dev/null +++ b/samples/browseable/MidiScope/src/com.example.android.common.midi/synth/SimpleAudioOutput.java @@ -0,0 +1,95 @@ +/* + * Copyright (C) 2015 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.example.android.common.midi.synth; + +import android.media.AudioFormat; +import android.media.AudioManager; +import android.media.AudioTrack; +import android.util.Log; + +/** + * Simple base class for implementing audio output for examples. + * This can be sub-classed for experimentation or to redirect audio output. + */ +public class SimpleAudioOutput { + + private static final String TAG = "AudioOutputTrack"; + public static final int SAMPLES_PER_FRAME = 2; + public static final int BYTES_PER_SAMPLE = 4; // float + public static final int BYTES_PER_FRAME = SAMPLES_PER_FRAME * BYTES_PER_SAMPLE; + private AudioTrack mAudioTrack; + private int mFrameRate; + + /** + * + */ + public SimpleAudioOutput() { + super(); + } + + /** + * Create an audio track then call play(). + * + * @param frameRate + */ + public void start(int frameRate) { + stop(); + mFrameRate = frameRate; + mAudioTrack = createAudioTrack(frameRate); + // AudioTrack will wait until it has enough data before starting. + mAudioTrack.play(); + } + + public AudioTrack createAudioTrack(int frameRate) { + int minBufferSizeBytes = AudioTrack.getMinBufferSize(frameRate, + AudioFormat.CHANNEL_OUT_STEREO, AudioFormat.ENCODING_PCM_FLOAT); + Log.i(TAG, "AudioTrack.minBufferSize = " + minBufferSizeBytes + + " bytes = " + (minBufferSizeBytes / BYTES_PER_FRAME) + + " frames"); + int bufferSize = 8 * minBufferSizeBytes / 8; + int outputBufferSizeFrames = bufferSize / BYTES_PER_FRAME; + Log.i(TAG, "actual bufferSize = " + bufferSize + " bytes = " + + outputBufferSizeFrames + " frames"); + + AudioTrack player = new AudioTrack(AudioManager.STREAM_MUSIC, + mFrameRate, AudioFormat.CHANNEL_OUT_STEREO, + AudioFormat.ENCODING_PCM_FLOAT, bufferSize, + AudioTrack.MODE_STREAM); + Log.i(TAG, "created AudioTrack"); + return player; + } + + public int write(float[] buffer, int offset, int length) { + return mAudioTrack.write(buffer, offset, length, + AudioTrack.WRITE_BLOCKING); + } + + public void stop() { + if (mAudioTrack != null) { + mAudioTrack.stop(); + mAudioTrack = null; + } + } + + public int getFrameRate() { + return mFrameRate; + } + + public AudioTrack getAudioTrack() { + return mAudioTrack; + } +} diff --git a/samples/browseable/MidiScope/src/com.example.android.common.midi/synth/SineOscillator.java b/samples/browseable/MidiScope/src/com.example.android.common.midi/synth/SineOscillator.java new file mode 100644 index 000000000..c638c344c --- /dev/null +++ b/samples/browseable/MidiScope/src/com.example.android.common.midi/synth/SineOscillator.java @@ -0,0 +1,54 @@ +/* + * Copyright (C) 2015 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.example.android.common.midi.synth; + +/** + * Sinewave oscillator. + */ +public class SineOscillator extends SawOscillator { + // Factorial constants. + private static final float IF3 = 1.0f / (2 * 3); + private static final float IF5 = IF3 / (4 * 5); + private static final float IF7 = IF5 / (6 * 7); + private static final float IF9 = IF7 / (8 * 9); + private static final float IF11 = IF9 / (10 * 11); + + /** + * Calculate sine using Taylor expansion. Do not use values outside the range. + * + * @param currentPhase in the range of -1.0 to +1.0 for one cycle + */ + public static float fastSin(float currentPhase) { + + /* Wrap phase back into region where results are more accurate. */ + float yp = (currentPhase > 0.5f) ? 1.0f - currentPhase + : ((currentPhase < (-0.5f)) ? (-1.0f) - currentPhase : currentPhase); + + float x = (float) (yp * Math.PI); + float x2 = (x * x); + /* Taylor expansion out to x**11/11! factored into multiply-adds */ + return x * (x2 * (x2 * (x2 * (x2 * ((x2 * (-IF11)) + IF9) - IF7) + IF5) - IF3) + 1); + } + + @Override + public float render() { + // Convert raw sawtooth to sine. + float phase = incrementWrapPhase(); + return fastSin(phase) * getAmplitude(); + } + +} diff --git a/samples/browseable/MidiScope/src/com.example.android.common.midi/synth/SineVoice.java b/samples/browseable/MidiScope/src/com.example.android.common.midi/synth/SineVoice.java new file mode 100644 index 000000000..e80d2c7eb --- /dev/null +++ b/samples/browseable/MidiScope/src/com.example.android.common.midi/synth/SineVoice.java @@ -0,0 +1,27 @@ +/* + * Copyright (C) 2015 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.example.android.common.midi.synth; + +/** + * Replace sawtooth with a sine wave. + */ +public class SineVoice extends SawVoice { + @Override + protected SawOscillator createOscillator() { + return new SineOscillator(); + } +} diff --git a/samples/browseable/MidiScope/src/com.example.android.common.midi/synth/SynthEngine.java b/samples/browseable/MidiScope/src/com.example.android.common.midi/synth/SynthEngine.java new file mode 100644 index 000000000..6cd02a609 --- /dev/null +++ b/samples/browseable/MidiScope/src/com.example.android.common.midi/synth/SynthEngine.java @@ -0,0 +1,284 @@ +/* + * Copyright (C) 2015 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.example.android.common.midi.synth; + +import android.media.midi.MidiReceiver; +import android.util.Log; + +import com.example.android.common.midi.MidiConstants; +import com.example.android.common.midi.MidiEventScheduler; +import com.example.android.common.midi.MidiEventScheduler.MidiEvent; +import com.example.android.common.midi.MidiFramer; + +import java.io.IOException; +import java.util.ArrayList; +import java.util.Hashtable; +import java.util.Iterator; + +/** + * Very simple polyphonic, single channel synthesizer. It runs a background + * thread that processes MIDI events and synthesizes audio. + */ +public class SynthEngine extends MidiReceiver { + + private static final String TAG = "SynthEngine"; + + public static final int FRAME_RATE = 48000; + private static final int FRAMES_PER_BUFFER = 240; + private static final int SAMPLES_PER_FRAME = 2; + + private boolean go; + private Thread mThread; + private float[] mBuffer = new float[FRAMES_PER_BUFFER * SAMPLES_PER_FRAME]; + private float mFrequencyScaler = 1.0f; + private float mBendRange = 2.0f; // semitones + private int mProgram; + + private ArrayList mFreeVoices = new ArrayList(); + private Hashtable + mVoices = new Hashtable(); + private MidiEventScheduler mEventScheduler; + private MidiFramer mFramer; + private MidiReceiver mReceiver = new MyReceiver(); + private SimpleAudioOutput mAudioOutput; + + public SynthEngine() { + this(new SimpleAudioOutput()); + } + + public SynthEngine(SimpleAudioOutput audioOutput) { + mReceiver = new MyReceiver(); + mFramer = new MidiFramer(mReceiver); + mAudioOutput = audioOutput; + } + + @Override + public void onSend(byte[] data, int offset, int count, long timestamp) + throws IOException { + if (mEventScheduler != null) { + if (!MidiConstants.isAllActiveSensing(data, offset, count)) { + mEventScheduler.getReceiver().send(data, offset, count, + timestamp); + } + } + } + + private class MyReceiver extends MidiReceiver { + @Override + public void onSend(byte[] data, int offset, int count, long timestamp) + throws IOException { + byte command = (byte) (data[0] & MidiConstants.STATUS_COMMAND_MASK); + int channel = (byte) (data[0] & MidiConstants.STATUS_CHANNEL_MASK); + switch (command) { + case MidiConstants.STATUS_NOTE_OFF: + noteOff(channel, data[1], data[2]); + break; + case MidiConstants.STATUS_NOTE_ON: + noteOn(channel, data[1], data[2]); + break; + case MidiConstants.STATUS_PITCH_BEND: + int bend = (data[2] << 7) + data[1]; + pitchBend(channel, bend); + break; + case MidiConstants.STATUS_PROGRAM_CHANGE: + mProgram = data[1]; + mFreeVoices.clear(); + break; + default: + logMidiMessage(data, offset, count); + break; + } + } + } + + class MyRunnable implements Runnable { + @Override + public void run() { + try { + mAudioOutput.start(FRAME_RATE); + onLoopStarted(); + while (go) { + processMidiEvents(); + generateBuffer(); + mAudioOutput.write(mBuffer, 0, mBuffer.length); + onBufferCompleted(FRAMES_PER_BUFFER); + } + } catch (Exception e) { + Log.e(TAG, "SynthEngine background thread exception.", e); + } finally { + onLoopEnded(); + mAudioOutput.stop(); + } + } + } + + /** + * This is called form the synthesis thread before it starts looping. + */ + public void onLoopStarted() { + } + + /** + * This is called once at the end of each synthesis loop. + * + * @param framesPerBuffer + */ + public void onBufferCompleted(int framesPerBuffer) { + } + + /** + * This is called form the synthesis thread when it stop looping. + */ + public void onLoopEnded() { + } + + /** + * Assume message has been aligned to the start of a MIDI message. + * + * @param data + * @param offset + * @param count + */ + public void logMidiMessage(byte[] data, int offset, int count) { + String text = "Received: "; + for (int i = 0; i < count; i++) { + text += String.format("0x%02X, ", data[offset + i]); + } + Log.i(TAG, text); + } + + /** + * @throws IOException + * + */ + private void processMidiEvents() throws IOException { + long now = System.nanoTime(); // TODO use audio presentation time + MidiEvent event = (MidiEvent) mEventScheduler.getNextEvent(now); + while (event != null) { + mFramer.send(event.data, 0, event.count, event.getTimestamp()); + mEventScheduler.addEventToPool(event); + event = (MidiEvent) mEventScheduler.getNextEvent(now); + } + } + + /** + * + */ + private void generateBuffer() { + for (int i = 0; i < mBuffer.length; i++) { + mBuffer[i] = 0.0f; + } + Iterator iterator = mVoices.values().iterator(); + while (iterator.hasNext()) { + SynthVoice voice = iterator.next(); + if (voice.isDone()) { + iterator.remove(); + // mFreeVoices.add(voice); + } else { + voice.mix(mBuffer, SAMPLES_PER_FRAME, 0.25f); + } + } + } + + public void noteOff(int channel, int noteIndex, int velocity) { + SynthVoice voice = mVoices.get(noteIndex); + if (voice != null) { + voice.noteOff(); + } + } + + public void allNotesOff() { + Iterator iterator = mVoices.values().iterator(); + while (iterator.hasNext()) { + SynthVoice voice = iterator.next(); + voice.noteOff(); + } + } + + /** + * Create a SynthVoice. + */ + public SynthVoice createVoice(int program) { + // For every odd program number use a sine wave. + if ((program & 1) == 1) { + return new SineVoice(); + } else { + return new SawVoice(); + } + } + + /** + * + * @param channel + * @param noteIndex + * @param velocity + */ + public void noteOn(int channel, int noteIndex, int velocity) { + if (velocity == 0) { + noteOff(channel, noteIndex, velocity); + } else { + mVoices.remove(noteIndex); + SynthVoice voice; + if (mFreeVoices.size() > 0) { + voice = mFreeVoices.remove(mFreeVoices.size() - 1); + } else { + voice = createVoice(mProgram); + } + voice.setFrequencyScaler(mFrequencyScaler); + voice.noteOn(noteIndex, velocity); + mVoices.put(noteIndex, voice); + } + } + + public void pitchBend(int channel, int bend) { + double semitones = (mBendRange * (bend - 0x2000)) / 0x2000; + mFrequencyScaler = (float) Math.pow(2.0, semitones / 12.0); + Iterator iterator = mVoices.values().iterator(); + while (iterator.hasNext()) { + SynthVoice voice = iterator.next(); + voice.setFrequencyScaler(mFrequencyScaler); + } + } + + /** + * Start the synthesizer. + */ + public void start() { + stop(); + go = true; + mThread = new Thread(new MyRunnable()); + mEventScheduler = new MidiEventScheduler(); + mThread.start(); + } + + /** + * Stop the synthesizer. + */ + public void stop() { + go = false; + if (mThread != null) { + try { + mThread.interrupt(); + mThread.join(500); + } catch (InterruptedException e) { + // OK, just stopping safely. + } + mThread = null; + mEventScheduler = null; + } + } +} diff --git a/samples/browseable/MidiScope/src/com.example.android.common.midi/synth/SynthUnit.java b/samples/browseable/MidiScope/src/com.example.android.common.midi/synth/SynthUnit.java new file mode 100644 index 000000000..90599e284 --- /dev/null +++ b/samples/browseable/MidiScope/src/com.example.android.common.midi/synth/SynthUnit.java @@ -0,0 +1,35 @@ +/* + * Copyright (C) 2015 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.example.android.common.midi.synth; + +public abstract class SynthUnit { + + private static final double CONCERT_A_PITCH = 69.0; + private static final double CONCERT_A_FREQUENCY = 440.0; + + /** + * @param pitch + * MIDI pitch in semitones + * @return frequency + */ + public static double pitchToFrequency(double pitch) { + double semitones = pitch - CONCERT_A_PITCH; + return CONCERT_A_FREQUENCY * Math.pow(2.0, semitones / 12.0); + } + + public abstract float render(); +} diff --git a/samples/browseable/MidiScope/src/com.example.android.common.midi/synth/SynthVoice.java b/samples/browseable/MidiScope/src/com.example.android.common.midi/synth/SynthVoice.java new file mode 100644 index 000000000..78ba09ac4 --- /dev/null +++ b/samples/browseable/MidiScope/src/com.example.android.common.midi/synth/SynthVoice.java @@ -0,0 +1,85 @@ +/* + * Copyright (C) 2015 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.example.android.common.midi.synth; + +/** + * Base class for a polyphonic synthesizer voice. + */ +public abstract class SynthVoice { + private int mNoteIndex; + private float mAmplitude; + public static final int STATE_OFF = 0; + public static final int STATE_ON = 1; + private int mState = STATE_OFF; + + public SynthVoice() { + mNoteIndex = -1; + } + + public void noteOn(int noteIndex, int velocity) { + mState = STATE_ON; + this.mNoteIndex = noteIndex; + setAmplitude(velocity / 128.0f); + } + + public void noteOff() { + mState = STATE_OFF; + } + + /** + * Add the output of this voice to an output buffer. + * + * @param outputBuffer + * @param samplesPerFrame + * @param level + */ + public void mix(float[] outputBuffer, int samplesPerFrame, float level) { + int numFrames = outputBuffer.length / samplesPerFrame; + for (int i = 0; i < numFrames; i++) { + float output = render(); + int offset = i * samplesPerFrame; + for (int jf = 0; jf < samplesPerFrame; jf++) { + outputBuffer[offset + jf] += output * level; + } + } + } + + public abstract float render(); + + public boolean isDone() { + return mState == STATE_OFF; + } + + public int getNoteIndex() { + return mNoteIndex; + } + + public float getAmplitude() { + return mAmplitude; + } + + public void setAmplitude(float amplitude) { + this.mAmplitude = amplitude; + } + + /** + * @param scaler + */ + public void setFrequencyScaler(float scaler) { + } + +} diff --git a/samples/browseable/MidiScope/src/com.example.android.midiscope/LoggingReceiver.java b/samples/browseable/MidiScope/src/com.example.android.midiscope/LoggingReceiver.java new file mode 100644 index 000000000..23ce8f7c0 --- /dev/null +++ b/samples/browseable/MidiScope/src/com.example.android.midiscope/LoggingReceiver.java @@ -0,0 +1,62 @@ +/* + * Copyright (C) 2015 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.example.android.midiscope; + +import android.media.midi.MidiReceiver; +import android.util.Log; + +import java.io.IOException; +import java.util.concurrent.TimeUnit; + +/** + * Convert incoming MIDI messages to a string and write them to a ScopeLogger. + * Assume that messages have been aligned using a MidiFramer. + */ +public class LoggingReceiver extends MidiReceiver { + public static final String TAG = "MidiScope"; + private static final long NANOS_PER_SECOND = TimeUnit.SECONDS.toNanos(1); + private long mStartTime; + private ScopeLogger mLogger; + + public LoggingReceiver(ScopeLogger logger) { + mStartTime = System.nanoTime(); + mLogger = logger; + } + + /* + * @see android.media.midi.MidiReceiver#onReceive(byte[], int, int, long) + */ + @Override + public void onSend(byte[] data, int offset, int count, long timestamp) + throws IOException { + StringBuilder sb = new StringBuilder(); + if (timestamp == 0) { + sb.append(String.format("-----0----: ")); + } else { + long monoTime = timestamp - mStartTime; + double seconds = (double) monoTime / NANOS_PER_SECOND; + sb.append(String.format("%10.3f: ", seconds)); + } + sb.append(MidiPrinter.formatBytes(data, offset, count)); + sb.append(": "); + sb.append(MidiPrinter.formatMessage(data, offset, count)); + String text = sb.toString(); + mLogger.log(text); + Log.i(TAG, text); + } + +} \ No newline at end of file diff --git a/samples/browseable/MidiScope/src/com.example.android.midiscope/MainActivity.java b/samples/browseable/MidiScope/src/com.example.android.midiscope/MainActivity.java new file mode 100644 index 000000000..41d74f035 --- /dev/null +++ b/samples/browseable/MidiScope/src/com.example.android.midiscope/MainActivity.java @@ -0,0 +1,162 @@ +/* + * Copyright (C) 2015 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.example.android.midiscope; + +import android.app.ActionBar; +import android.app.Activity; +import android.media.midi.MidiDeviceInfo; +import android.media.midi.MidiManager; +import android.media.midi.MidiReceiver; +import android.os.Bundle; +import android.view.Menu; +import android.view.MenuItem; +import android.view.View; +import android.view.WindowManager; +import android.widget.ScrollView; +import android.widget.TextView; +import android.widget.Toolbar; + +import com.example.android.common.midi.MidiFramer; +import com.example.android.common.midi.MidiOutputPortSelector; +import com.example.android.common.midi.MidiPortWrapper; + +import java.util.LinkedList; + +/** + * App that provides a MIDI echo service. + */ +public class MainActivity extends Activity implements ScopeLogger { + + private static final int MAX_LINES = 100; + + private final LinkedList mLogLines = new LinkedList<>(); + private TextView mLog; + private ScrollView mScroller; + private MidiOutputPortSelector mLogSenderSelector; + + @Override + public void onCreate(Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + setContentView(R.layout.main); + + setActionBar((Toolbar) findViewById(R.id.toolbar)); + ActionBar actionBar = getActionBar(); + if (actionBar != null) { + actionBar.setDisplayShowTitleEnabled(false); + } + + mLog = (TextView) findViewById(R.id.log); + mScroller = (ScrollView) findViewById(R.id.scroll); + + // Setup MIDI + MidiManager midiManager = (MidiManager) getSystemService(MIDI_SERVICE); + + // Receiver that prints the messages. + MidiReceiver loggingReceiver = new LoggingReceiver(this); + + // Receiver that parses raw data into complete messages. + MidiFramer connectFramer = new MidiFramer(loggingReceiver); + + // Setup a menu to select an input source. + mLogSenderSelector = new MidiOutputPortSelector(midiManager, this, R.id.spinner_senders) { + @Override + public void onPortSelected(final MidiPortWrapper wrapper) { + super.onPortSelected(wrapper); + if (wrapper != null) { + mLogLines.clear(); + MidiDeviceInfo deviceInfo = wrapper.getDeviceInfo(); + if (deviceInfo == null) { + log(getString(R.string.header_text)); + } else { + log(MidiPrinter.formatDeviceInfo(deviceInfo)); + } + } + } + }; + mLogSenderSelector.getSender().connect(connectFramer); + + // Tell the virtual device to log its messages here.. + MidiScope.setScopeLogger(this); + } + + @Override + public void onDestroy() { + mLogSenderSelector.onClose(); + // The scope will live on as a service so we need to tell it to stop + // writing log messages to this Activity. + MidiScope.setScopeLogger(null); + super.onDestroy(); + } + + @Override + public boolean onCreateOptionsMenu(Menu menu) { + getMenuInflater().inflate(R.menu.main, menu); + setKeepScreenOn(menu.findItem(R.id.action_keep_screen_on).isChecked()); + return true; + } + + @Override + public boolean onOptionsItemSelected(MenuItem item) { + switch (item.getItemId()) { + case R.id.action_clear_all: + mLogLines.clear(); + logOnUiThread(""); + break; + case R.id.action_keep_screen_on: + boolean checked = !item.isChecked(); + setKeepScreenOn(checked); + item.setChecked(checked); + break; + } + return super.onOptionsItemSelected(item); + } + + private void setKeepScreenOn(boolean keepScreenOn) { + if (keepScreenOn) { + getWindow().addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON); + } else { + getWindow().clearFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON); + } + } + + @Override + public void log(final String string) { + runOnUiThread(new Runnable() { + @Override + public void run() { + logOnUiThread(string); + } + }); + } + + /** + * Logs a message to our TextView. This needs to be called from the UI thread. + */ + private void logOnUiThread(String s) { + mLogLines.add(s); + if (mLogLines.size() > MAX_LINES) { + mLogLines.removeFirst(); + } + // Render line buffer to one String. + StringBuilder sb = new StringBuilder(); + for (String line : mLogLines) { + sb.append(line).append('\n'); + } + mLog.setText(sb.toString()); + mScroller.fullScroll(View.FOCUS_DOWN); + } +} diff --git a/samples/browseable/MidiScope/src/com.example.android.midiscope/MidiPrinter.java b/samples/browseable/MidiScope/src/com.example.android.midiscope/MidiPrinter.java new file mode 100644 index 000000000..9e97c04cb --- /dev/null +++ b/samples/browseable/MidiScope/src/com.example.android.midiscope/MidiPrinter.java @@ -0,0 +1,108 @@ +/* + * Copyright (C) 2015 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.example.android.midiscope; + +import android.media.midi.MidiDeviceInfo; +import android.media.midi.MidiDeviceInfo.PortInfo; +import android.os.Bundle; + +import com.example.android.common.midi.MidiConstants; + +/** + * Format a MIDI message for printing. + */ +public class MidiPrinter { + + public static final String[] CHANNEL_COMMAND_NAMES = { "NoteOff", "NoteOn", + "PolyTouch", "Control", "Program", "Pressure", "Bend" }; + public static final String[] SYSTEM_COMMAND_NAMES = { "SysEx", // F0 + "TimeCode", // F1 + "SongPos", // F2 + "SongSel", // F3 + "F4", // F4 + "F5", // F5 + "TuneReq", // F6 + "EndSysex", // F7 + "TimingClock", // F8 + "F9", // F9 + "Start", // FA + "Continue", // FB + "Stop", // FC + "FD", // FD + "ActiveSensing", // FE + "Reset" // FF + }; + + public static String getName(int status) { + if (status >= 0xF0) { + int index = status & 0x0F; + return SYSTEM_COMMAND_NAMES[index]; + } else if (status >= 0x80) { + int index = (status >> 4) & 0x07; + return CHANNEL_COMMAND_NAMES[index]; + } else { + return "data"; + } + } + + public static String formatBytes(byte[] data, int offset, int count) { + StringBuilder sb = new StringBuilder(); + for (int i = 0; i < count; i++) { + sb.append(String.format(" %02X", data[offset + i])); + } + return sb.toString(); + } + + public static String formatMessage(byte[] data, int offset, int count) { + StringBuilder sb = new StringBuilder(); + byte statusByte = data[offset++]; + int status = statusByte & 0xFF; + sb.append(getName(status)).append("("); + int numData = MidiConstants.getBytesPerMessage(statusByte) - 1; + if ((status >= 0x80) && (status < 0xF0)) { // channel message + int channel = status & 0x0F; + // Add 1 for humans who think channels are numbered 1-16. + sb.append((channel + 1)).append(", "); + } + for (int i = 0; i < numData; i++) { + if (i > 0) { + sb.append(", "); + } + sb.append(data[offset++]); + } + sb.append(")"); + return sb.toString(); + } + + public static String formatDeviceInfo(MidiDeviceInfo info) { + StringBuilder sb = new StringBuilder(); + if (info != null) { + Bundle properties = info.getProperties(); + for (String key : properties.keySet()) { + Object value = properties.get(key); + sb.append(key).append(" = ").append(value).append('\n'); + } + for (PortInfo port : info.getPorts()) { + sb.append((port.getType() == PortInfo.TYPE_INPUT) ? "input" + : "output"); + sb.append("[").append(port.getPortNumber()).append("] = \"").append(port.getName() + + "\"\n"); + } + } + return sb.toString(); + } +} diff --git a/samples/browseable/MidiScope/src/com.example.android.midiscope/MidiScope.java b/samples/browseable/MidiScope/src/com.example.android.midiscope/MidiScope.java new file mode 100644 index 000000000..3965d83cf --- /dev/null +++ b/samples/browseable/MidiScope/src/com.example.android.midiscope/MidiScope.java @@ -0,0 +1,83 @@ +/* + * Copyright (C) 2015 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.example.android.midiscope; + +import android.media.midi.MidiDeviceService; +import android.media.midi.MidiDeviceStatus; +import android.media.midi.MidiReceiver; + +import com.example.android.common.midi.MidiFramer; + +import java.io.IOException; + +/** + * Virtual MIDI Device that logs messages to a ScopeLogger. + */ + +public class MidiScope extends MidiDeviceService { + + private static ScopeLogger mScopeLogger; + private MidiReceiver mInputReceiver = new MyReceiver(); + private static MidiFramer mDeviceFramer; + + @Override + public MidiReceiver[] onGetInputPortReceivers() { + return new MidiReceiver[] { mInputReceiver }; + } + + public static ScopeLogger getScopeLogger() { + return mScopeLogger; + } + + public static void setScopeLogger(ScopeLogger logger) { + if (logger != null) { + // Receiver that prints the messages. + LoggingReceiver loggingReceiver = new LoggingReceiver(logger); + mDeviceFramer = new MidiFramer(loggingReceiver); + } + mScopeLogger = logger; + } + + private static class MyReceiver extends MidiReceiver { + @Override + public void onSend(byte[] data, int offset, int count, + long timestamp) throws IOException { + if (mScopeLogger != null) { + // Send raw data to be parsed into discrete messages. + mDeviceFramer.send(data, offset, count, timestamp); + } + } + } + + /** + * This will get called when clients connect or disconnect. + * Log device information. + */ + @Override + public void onDeviceStatusChanged(MidiDeviceStatus status) { + if (mScopeLogger != null) { + if (status.isInputPortOpen(0)) { + mScopeLogger.log("=== connected ==="); + String text = MidiPrinter.formatDeviceInfo( + status.getDeviceInfo()); + mScopeLogger.log(text); + } else { + mScopeLogger.log("--- disconnected ---"); + } + } + } +} diff --git a/samples/browseable/MidiScope/src/com.example.android.midiscope/ScopeLogger.java b/samples/browseable/MidiScope/src/com.example.android.midiscope/ScopeLogger.java new file mode 100644 index 000000000..dc52efd65 --- /dev/null +++ b/samples/browseable/MidiScope/src/com.example.android.midiscope/ScopeLogger.java @@ -0,0 +1,25 @@ +/* + * Copyright (C) 2015 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.example.android.midiscope; + +public interface ScopeLogger { + /** + * Write the text string somewhere that the user can see it. + * @param text + */ + void log(String text); +} diff --git a/samples/browseable/MidiSynth/AndroidManifest.xml b/samples/browseable/MidiSynth/AndroidManifest.xml new file mode 100644 index 000000000..100241920 --- /dev/null +++ b/samples/browseable/MidiSynth/AndroidManifest.xml @@ -0,0 +1,55 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/samples/browseable/MidiSynth/_index.jd b/samples/browseable/MidiSynth/_index.jd new file mode 100644 index 000000000..33842a0dd --- /dev/null +++ b/samples/browseable/MidiSynth/_index.jd @@ -0,0 +1,11 @@ + +page.tags="MidiSynth" +sample.group=Media +@jd:body + +

+ +This sample demonstrates how to use the MIDI API to receive and play MIDI messages coming from an +attached input device. + +

diff --git a/samples/browseable/MidiSynth/res/drawable-hdpi/tile.9.png b/samples/browseable/MidiSynth/res/drawable-hdpi/tile.9.png new file mode 100644 index 000000000..135862883 Binary files /dev/null and b/samples/browseable/MidiSynth/res/drawable-hdpi/tile.9.png differ diff --git a/samples/browseable/MidiSynth/res/layout/main.xml b/samples/browseable/MidiSynth/res/layout/main.xml new file mode 100644 index 000000000..6b9e2c7b5 --- /dev/null +++ b/samples/browseable/MidiSynth/res/layout/main.xml @@ -0,0 +1,53 @@ + + + + + + + + + + + + + diff --git a/samples/browseable/MidiSynth/res/menu/main.xml b/samples/browseable/MidiSynth/res/menu/main.xml new file mode 100644 index 000000000..33093f0a5 --- /dev/null +++ b/samples/browseable/MidiSynth/res/menu/main.xml @@ -0,0 +1,25 @@ + + + + + + diff --git a/samples/browseable/MidiSynth/res/mipmap-hdpi/ic_launcher.png b/samples/browseable/MidiSynth/res/mipmap-hdpi/ic_launcher.png new file mode 100644 index 000000000..38250e7f2 Binary files /dev/null and b/samples/browseable/MidiSynth/res/mipmap-hdpi/ic_launcher.png differ diff --git a/samples/browseable/MidiSynth/res/mipmap-mdpi/ic_launcher.png b/samples/browseable/MidiSynth/res/mipmap-mdpi/ic_launcher.png new file mode 100644 index 000000000..58c0025b0 Binary files /dev/null and b/samples/browseable/MidiSynth/res/mipmap-mdpi/ic_launcher.png differ diff --git a/samples/browseable/MidiSynth/res/mipmap-xhdpi/ic_launcher.png b/samples/browseable/MidiSynth/res/mipmap-xhdpi/ic_launcher.png new file mode 100644 index 000000000..369485533 Binary files /dev/null and b/samples/browseable/MidiSynth/res/mipmap-xhdpi/ic_launcher.png differ diff --git a/samples/browseable/MidiSynth/res/mipmap-xxhdpi/ic_launcher.png b/samples/browseable/MidiSynth/res/mipmap-xxhdpi/ic_launcher.png new file mode 100644 index 000000000..fb50ad157 Binary files /dev/null and b/samples/browseable/MidiSynth/res/mipmap-xxhdpi/ic_launcher.png differ diff --git a/samples/browseable/MidiSynth/res/mipmap-xxxhdpi/ic_launcher.png b/samples/browseable/MidiSynth/res/mipmap-xxxhdpi/ic_launcher.png new file mode 100644 index 000000000..801bf9c10 Binary files /dev/null and b/samples/browseable/MidiSynth/res/mipmap-xxxhdpi/ic_launcher.png differ diff --git a/samples/browseable/MidiSynth/res/values-sw600dp/template-dimens.xml b/samples/browseable/MidiSynth/res/values-sw600dp/template-dimens.xml new file mode 100644 index 000000000..22074a2bd --- /dev/null +++ b/samples/browseable/MidiSynth/res/values-sw600dp/template-dimens.xml @@ -0,0 +1,24 @@ + + + + + + + @dimen/margin_huge + @dimen/margin_medium + + diff --git a/samples/browseable/MidiSynth/res/values-sw600dp/template-styles.xml b/samples/browseable/MidiSynth/res/values-sw600dp/template-styles.xml new file mode 100644 index 000000000..03d197418 --- /dev/null +++ b/samples/browseable/MidiSynth/res/values-sw600dp/template-styles.xml @@ -0,0 +1,25 @@ + + + + + + + diff --git a/samples/browseable/MidiSynth/res/values-v11/template-styles.xml b/samples/browseable/MidiSynth/res/values-v11/template-styles.xml new file mode 100644 index 000000000..8c1ea66f2 --- /dev/null +++ b/samples/browseable/MidiSynth/res/values-v11/template-styles.xml @@ -0,0 +1,22 @@ + + + + + + + + diff --git a/samples/browseable/MidiSynth/res/values/base-strings.xml b/samples/browseable/MidiSynth/res/values/base-strings.xml new file mode 100644 index 000000000..d38815ff6 --- /dev/null +++ b/samples/browseable/MidiSynth/res/values/base-strings.xml @@ -0,0 +1,30 @@ + + + + + MidiSynth + + + + diff --git a/samples/browseable/MidiSynth/res/values/colors.xml b/samples/browseable/MidiSynth/res/values/colors.xml new file mode 100644 index 000000000..4d2204f9d --- /dev/null +++ b/samples/browseable/MidiSynth/res/values/colors.xml @@ -0,0 +1,26 @@ + + + + #4CAF50 + #388E3C + #C8E6C9 + #FFEB3B + #212121 + #727272 + #FFFFFF + #B6B6B6 + diff --git a/samples/browseable/MidiSynth/res/values/strings.xml b/samples/browseable/MidiSynth/res/values/strings.xml new file mode 100644 index 000000000..76a8fa266 --- /dev/null +++ b/samples/browseable/MidiSynth/res/values/strings.xml @@ -0,0 +1,25 @@ + + + + Select Sender for Synth + Selected port is in use or unavailable. + Port opened OK. + + "none" + + Keep screen on + diff --git a/samples/browseable/MidiSynth/res/values/styles.xml b/samples/browseable/MidiSynth/res/values/styles.xml new file mode 100644 index 000000000..30d645565 --- /dev/null +++ b/samples/browseable/MidiSynth/res/values/styles.xml @@ -0,0 +1,25 @@ + + + + + + + diff --git a/samples/browseable/MidiSynth/res/values/template-dimens.xml b/samples/browseable/MidiSynth/res/values/template-dimens.xml new file mode 100644 index 000000000..39e710b5c --- /dev/null +++ b/samples/browseable/MidiSynth/res/values/template-dimens.xml @@ -0,0 +1,32 @@ + + + + + + + 4dp + 8dp + 16dp + 32dp + 64dp + + + + @dimen/margin_medium + @dimen/margin_medium + + diff --git a/samples/browseable/MidiSynth/res/values/template-styles.xml b/samples/browseable/MidiSynth/res/values/template-styles.xml new file mode 100644 index 000000000..6e7d593dd --- /dev/null +++ b/samples/browseable/MidiSynth/res/values/template-styles.xml @@ -0,0 +1,42 @@ + + + + + + + + + + + diff --git a/samples/browseable/MidiSynth/res/xml/synth_device_info.xml b/samples/browseable/MidiSynth/res/xml/synth_device_info.xml new file mode 100644 index 000000000..405e87e11 --- /dev/null +++ b/samples/browseable/MidiSynth/res/xml/synth_device_info.xml @@ -0,0 +1,21 @@ + + + + + + + diff --git a/samples/browseable/MidiSynth/src/com.example.android.common.midi/EventScheduler.java b/samples/browseable/MidiSynth/src/com.example.android.common.midi/EventScheduler.java new file mode 100644 index 000000000..37c0140dc --- /dev/null +++ b/samples/browseable/MidiSynth/src/com.example.android.common.midi/EventScheduler.java @@ -0,0 +1,243 @@ +/* + * Copyright (C) 2015 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.example.android.common.midi; + +import java.util.SortedMap; +import java.util.TreeMap; + +/** + * Store SchedulableEvents in a timestamped buffer. + * Events may be written in any order. + * Events will be read in sorted order. + * Events with the same timestamp will be read in the order they were added. + * + * Only one Thread can write into the buffer. + * And only one Thread can read from the buffer. + */ +public class EventScheduler { + private static final long NANOS_PER_MILLI = 1000000; + + private final Object lock = new Object(); + private SortedMap mEventBuffer; + // This does not have to be guarded. It is only set by the writing thread. + // If the reader sees a null right before being set then that is OK. + private FastEventQueue mEventPool = null; + private static final int MAX_POOL_SIZE = 200; + + public EventScheduler() { + mEventBuffer = new TreeMap(); + } + + // If we keep at least one node in the list then it can be atomic + // and non-blocking. + private class FastEventQueue { + // One thread takes from the beginning of the list. + volatile SchedulableEvent mFirst; + // A second thread returns events to the end of the list. + volatile SchedulableEvent mLast; + volatile long mEventsAdded; + volatile long mEventsRemoved; + + FastEventQueue(SchedulableEvent event) { + mFirst = event; + mLast = mFirst; + mEventsAdded = 1; // Always created with one event added. Never empty. + mEventsRemoved = 0; // None removed yet. + } + + int size() { + return (int)(mEventsAdded - mEventsRemoved); + } + + /** + * Do not call this unless there is more than one event + * in the list. + * @return first event in the list + */ + public SchedulableEvent remove() { + // Take first event. + mEventsRemoved++; + SchedulableEvent event = mFirst; + mFirst = event.mNext; + return event; + } + + /** + * @param event + */ + public void add(SchedulableEvent event) { + event.mNext = null; + mLast.mNext = event; + mLast = event; + mEventsAdded++; + } + } + + /** + * Base class for events that can be stored in the EventScheduler. + */ + public static class SchedulableEvent { + private long mTimestamp; + private SchedulableEvent mNext = null; + + /** + * @param timestamp + */ + public SchedulableEvent(long timestamp) { + mTimestamp = timestamp; + } + + /** + * @return timestamp + */ + public long getTimestamp() { + return mTimestamp; + } + + /** + * The timestamp should not be modified when the event is in the + * scheduling buffer. + */ + public void setTimestamp(long timestamp) { + mTimestamp = timestamp; + } + } + + /** + * Get an event from the pool. + * Always leave at least one event in the pool. + * @return event or null + */ + public SchedulableEvent removeEventfromPool() { + SchedulableEvent event = null; + if (mEventPool != null && (mEventPool.size() > 1)) { + event = mEventPool.remove(); + } + return event; + } + + /** + * Return events to a pool so they can be reused. + * + * @param event + */ + public void addEventToPool(SchedulableEvent event) { + if (mEventPool == null) { + mEventPool = new FastEventQueue(event); + // If we already have enough items in the pool then just + // drop the event. This prevents unbounded memory leaks. + } else if (mEventPool.size() < MAX_POOL_SIZE) { + mEventPool.add(event); + } + } + + /** + * Add an event to the scheduler. Events with the same time will be + * processed in order. + * + * @param event + */ + public void add(SchedulableEvent event) { + synchronized (lock) { + FastEventQueue list = mEventBuffer.get(event.getTimestamp()); + if (list == null) { + long lowestTime = mEventBuffer.isEmpty() ? Long.MAX_VALUE + : mEventBuffer.firstKey(); + list = new FastEventQueue(event); + mEventBuffer.put(event.getTimestamp(), list); + // If the event we added is earlier than the previous earliest + // event then notify any threads waiting for the next event. + if (event.getTimestamp() < lowestTime) { + lock.notify(); + } + } else { + list.add(event); + } + } + } + + // Caller must synchronize on lock before calling. + private SchedulableEvent removeNextEventLocked(long lowestTime) { + SchedulableEvent event; + FastEventQueue list = mEventBuffer.get(lowestTime); + // Remove list from tree if this is the last node. + if ((list.size() == 1)) { + mEventBuffer.remove(lowestTime); + } + event = list.remove(); + return event; + } + + /** + * Check to see if any scheduled events are ready to be processed. + * + * @param timestamp + * @return next event or null if none ready + */ + public SchedulableEvent getNextEvent(long time) { + SchedulableEvent event = null; + synchronized (lock) { + if (!mEventBuffer.isEmpty()) { + long lowestTime = mEventBuffer.firstKey(); + // Is it time for this list to be processed? + if (lowestTime <= time) { + event = removeNextEventLocked(lowestTime); + } + } + } + // Log.i(TAG, "getNextEvent: event = " + event); + return event; + } + + /** + * Return the next available event or wait until there is an event ready to + * be processed. This method assumes that the timestamps are in nanoseconds + * and that the current time is System.nanoTime(). + * + * @return event + * @throws InterruptedException + */ + public SchedulableEvent waitNextEvent() throws InterruptedException { + SchedulableEvent event = null; + while (true) { + long millisToWait = Integer.MAX_VALUE; + synchronized (lock) { + if (!mEventBuffer.isEmpty()) { + long now = System.nanoTime(); + long lowestTime = mEventBuffer.firstKey(); + // Is it time for the earliest list to be processed? + if (lowestTime <= now) { + event = removeNextEventLocked(lowestTime); + break; + } else { + // Figure out how long to sleep until next event. + long nanosToWait = lowestTime - now; + // Add 1 millisecond so we don't wake up before it is + // ready. + millisToWait = 1 + (nanosToWait / NANOS_PER_MILLI); + // Clip 64-bit value to 32-bit max. + if (millisToWait > Integer.MAX_VALUE) { + millisToWait = Integer.MAX_VALUE; + } + } + } + lock.wait((int) millisToWait); + } + } + return event; + } +} diff --git a/samples/browseable/MidiSynth/src/com.example.android.common.midi/MidiConstants.java b/samples/browseable/MidiSynth/src/com.example.android.common.midi/MidiConstants.java new file mode 100644 index 000000000..38c25d505 --- /dev/null +++ b/samples/browseable/MidiSynth/src/com.example.android.common.midi/MidiConstants.java @@ -0,0 +1,102 @@ +/* + * Copyright (C) 2015 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.example.android.common.midi; + +/** + * MIDI related constants and static methods. + * These values are defined in the MIDI Standard 1.0 + * available from the MIDI Manufacturers Association. + */ +public class MidiConstants { + protected final static String TAG = "MidiTools"; + public static final byte STATUS_COMMAND_MASK = (byte) 0xF0; + public static final byte STATUS_CHANNEL_MASK = (byte) 0x0F; + + // Channel voice messages. + public static final byte STATUS_NOTE_OFF = (byte) 0x80; + public static final byte STATUS_NOTE_ON = (byte) 0x90; + public static final byte STATUS_POLYPHONIC_AFTERTOUCH = (byte) 0xA0; + public static final byte STATUS_CONTROL_CHANGE = (byte) 0xB0; + public static final byte STATUS_PROGRAM_CHANGE = (byte) 0xC0; + public static final byte STATUS_CHANNEL_PRESSURE = (byte) 0xD0; + public static final byte STATUS_PITCH_BEND = (byte) 0xE0; + + // System Common Messages. + public static final byte STATUS_SYSTEM_EXCLUSIVE = (byte) 0xF0; + public static final byte STATUS_MIDI_TIME_CODE = (byte) 0xF1; + public static final byte STATUS_SONG_POSITION = (byte) 0xF2; + public static final byte STATUS_SONG_SELECT = (byte) 0xF3; + public static final byte STATUS_TUNE_REQUEST = (byte) 0xF6; + public static final byte STATUS_END_SYSEX = (byte) 0xF7; + + // System Real-Time Messages + public static final byte STATUS_TIMING_CLOCK = (byte) 0xF8; + public static final byte STATUS_START = (byte) 0xFA; + public static final byte STATUS_CONTINUE = (byte) 0xFB; + public static final byte STATUS_STOP = (byte) 0xFC; + public static final byte STATUS_ACTIVE_SENSING = (byte) 0xFE; + public static final byte STATUS_RESET = (byte) 0xFF; + + /** Number of bytes in a message nc from 8c to Ec */ + public final static int CHANNEL_BYTE_LENGTHS[] = { 3, 3, 3, 3, 2, 2, 3 }; + + /** Number of bytes in a message Fn from F0 to FF */ + public final static int SYSTEM_BYTE_LENGTHS[] = { 1, 2, 3, 2, 1, 1, 1, 1, 1, + 1, 1, 1, 1, 1, 1, 1 }; + + /** + * MIDI messages, except for SysEx, are 1,2 or 3 bytes long. + * You can tell how long a MIDI message is from the first status byte. + * Do not call this for SysEx, which has variable length. + * @param statusByte + * @return number of bytes in a complete message, zero if data byte passed + */ + public static int getBytesPerMessage(byte statusByte) { + // Java bytes are signed so we need to mask off the high bits + // to get a value between 0 and 255. + int statusInt = statusByte & 0xFF; + if (statusInt >= 0xF0) { + // System messages use low nibble for size. + return SYSTEM_BYTE_LENGTHS[statusInt & 0x0F]; + } else if(statusInt >= 0x80) { + // Channel voice messages use high nibble for size. + return CHANNEL_BYTE_LENGTHS[(statusInt >> 4) - 8]; + } else { + return 0; // data byte + } + } + + /** + * @param msg + * @param offset + * @param count + * @return true if the entire message is ActiveSensing commands + */ + public static boolean isAllActiveSensing(byte[] msg, int offset, + int count) { + // Count bytes that are not active sensing. + int goodBytes = 0; + for (int i = 0; i < count; i++) { + byte b = msg[offset + i]; + if (b != MidiConstants.STATUS_ACTIVE_SENSING) { + goodBytes++; + } + } + return (goodBytes == 0); + } + +} diff --git a/samples/browseable/MidiSynth/src/com.example.android.common.midi/MidiDispatcher.java b/samples/browseable/MidiSynth/src/com.example.android.common.midi/MidiDispatcher.java new file mode 100644 index 000000000..b7f1fe1e8 --- /dev/null +++ b/samples/browseable/MidiSynth/src/com.example.android.common.midi/MidiDispatcher.java @@ -0,0 +1,95 @@ +/* + * Copyright (C) 2015 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.example.android.common.midi; + +import android.media.midi.MidiReceiver; +import android.media.midi.MidiSender; + +import java.io.IOException; +import java.util.concurrent.CopyOnWriteArrayList; + +/** + * Utility class for dispatching MIDI data to a list of {@link MidiReceiver}s. + * This class subclasses {@link MidiReceiver} and dispatches any data it receives + * to its receiver list. Any receivers that throw an exception upon receiving data will + * be automatically removed from the receiver list, but no IOException will be returned + * from the dispatcher's {@link MidiReceiver#onReceive} in that case. + */ +public final class MidiDispatcher extends MidiReceiver { + + private final CopyOnWriteArrayList mReceivers + = new CopyOnWriteArrayList(); + + private final MidiSender mSender = new MidiSender() { + /** + * Called to connect a {@link MidiReceiver} to the sender + * + * @param receiver the receiver to connect + */ + @Override + public void onConnect(MidiReceiver receiver) { + mReceivers.add(receiver); + } + + /** + * Called to disconnect a {@link MidiReceiver} from the sender + * + * @param receiver the receiver to disconnect + */ + @Override + public void onDisconnect(MidiReceiver receiver) { + mReceivers.remove(receiver); + } + }; + + /** + * Returns the number of {@link MidiReceiver}s this dispatcher contains. + * @return the number of receivers + */ + public int getReceiverCount() { + return mReceivers.size(); + } + + /** + * Returns a {@link MidiSender} which is used to add and remove + * {@link MidiReceiver}s + * to the dispatcher's receiver list. + * @return the dispatcher's MidiSender + */ + public MidiSender getSender() { + return mSender; + } + + @Override + public void onSend(byte[] msg, int offset, int count, long timestamp) throws IOException { + for (MidiReceiver receiver : mReceivers) { + try { + receiver.send(msg, offset, count, timestamp); + } catch (IOException e) { + // if the receiver fails we remove the receiver but do not propagate the exception + mReceivers.remove(receiver); + } + } + } + + @Override + public void flush() throws IOException { + for (MidiReceiver receiver : mReceivers) { + receiver.flush(); + } + } +} diff --git a/samples/browseable/MidiSynth/src/com.example.android.common.midi/MidiEventScheduler.java b/samples/browseable/MidiSynth/src/com.example.android.common.midi/MidiEventScheduler.java new file mode 100644 index 000000000..513d3939b --- /dev/null +++ b/samples/browseable/MidiSynth/src/com.example.android.common.midi/MidiEventScheduler.java @@ -0,0 +1,119 @@ +/* + * Copyright (C) 2015 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.example.android.common.midi; + +import android.media.midi.MidiReceiver; + +import java.io.IOException; + +/** + * Add MIDI Events to an EventScheduler + */ +public class MidiEventScheduler extends EventScheduler { + private static final String TAG = "MidiEventScheduler"; + // Maintain a pool of scheduled events to reduce memory allocation. + // This pool increases performance by about 14%. + private final static int POOL_EVENT_SIZE = 16; + private MidiReceiver mReceiver = new SchedulingReceiver(); + + private class SchedulingReceiver extends MidiReceiver + { + /** + * Store these bytes in the EventScheduler to be delivered at the specified + * time. + */ + @Override + public void onSend(byte[] msg, int offset, int count, long timestamp) + throws IOException { + MidiEvent event = createScheduledEvent(msg, offset, count, timestamp); + if (event != null) { + add(event); + } + } + } + + public static class MidiEvent extends SchedulableEvent { + public int count = 0; + public byte[] data; + + private MidiEvent(int count) { + super(0); + data = new byte[count]; + } + + private MidiEvent(byte[] msg, int offset, int count, long timestamp) { + super(timestamp); + data = new byte[count]; + System.arraycopy(msg, offset, data, 0, count); + this.count = count; + } + + @Override + public String toString() { + String text = "Event: "; + for (int i = 0; i < count; i++) { + text += data[i] + ", "; + } + return text; + } + } + + /** + * Create an event that contains the message. + */ + private MidiEvent createScheduledEvent(byte[] msg, int offset, int count, + long timestamp) { + MidiEvent event; + if (count > POOL_EVENT_SIZE) { + event = new MidiEvent(msg, offset, count, timestamp); + } else { + event = (MidiEvent) removeEventfromPool(); + if (event == null) { + event = new MidiEvent(POOL_EVENT_SIZE); + } + System.arraycopy(msg, offset, event.data, 0, count); + event.count = count; + event.setTimestamp(timestamp); + } + return event; + } + + /** + * Return events to a pool so they can be reused. + * + * @param event + */ + @Override + public void addEventToPool(SchedulableEvent event) { + // Make sure the event is suitable for the pool. + if (event instanceof MidiEvent) { + MidiEvent midiEvent = (MidiEvent) event; + if (midiEvent.data.length == POOL_EVENT_SIZE) { + super.addEventToPool(event); + } + } + } + + /** + * This MidiReceiver will write date to the scheduling buffer. + * @return the MidiReceiver + */ + public MidiReceiver getReceiver() { + return mReceiver; + } + +} diff --git a/samples/browseable/MidiSynth/src/com.example.android.common.midi/MidiEventThread.java b/samples/browseable/MidiSynth/src/com.example.android.common.midi/MidiEventThread.java new file mode 100644 index 000000000..626e83cf0 --- /dev/null +++ b/samples/browseable/MidiSynth/src/com.example.android.common.midi/MidiEventThread.java @@ -0,0 +1,90 @@ +/* + * Copyright (C) 2015 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.example.android.common.midi; + +import android.media.midi.MidiSender; +import android.util.Log; + +import java.io.IOException; + +public class MidiEventThread extends MidiEventScheduler { + protected static final String TAG = "MidiEventThread"; + + private EventThread mEventThread; + MidiDispatcher mDispatcher = new MidiDispatcher(); + + class EventThread extends Thread { + private boolean go = true; + + @Override + public void run() { + while (go) { + try { + MidiEvent event = (MidiEvent) waitNextEvent(); + try { + Log.i(TAG, "Fire event " + event.data[0] + " at " + + event.getTimestamp()); + mDispatcher.send(event.data, 0, + event.count, event.getTimestamp()); + } catch (IOException e) { + e.printStackTrace(); + } + // Put event back in the pool for future use. + addEventToPool(event); + } catch (InterruptedException e) { + // OK, this is how we stop the thread. + } + } + } + + /** + * Asynchronously tell the thread to stop. + */ + public void requestStop() { + go = false; + interrupt(); + } + } + + public void start() { + stop(); + mEventThread = new EventThread(); + mEventThread.start(); + } + + /** + * Asks the thread to stop then waits for it to stop. + */ + public void stop() { + if (mEventThread != null) { + mEventThread.requestStop(); + try { + mEventThread.join(500); + } catch (InterruptedException e) { + Log.e(TAG, + "Interrupted while waiting for MIDI EventScheduler thread to stop."); + } finally { + mEventThread = null; + } + } + } + + public MidiSender getSender() { + return mDispatcher.getSender(); + } + +} diff --git a/samples/browseable/MidiSynth/src/com.example.android.common.midi/MidiFramer.java b/samples/browseable/MidiSynth/src/com.example.android.common.midi/MidiFramer.java new file mode 100644 index 000000000..c274925ac --- /dev/null +++ b/samples/browseable/MidiSynth/src/com.example.android.common.midi/MidiFramer.java @@ -0,0 +1,112 @@ +/* + * Copyright (C) 2015 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.example.android.common.midi; + +import android.media.midi.MidiReceiver; +import android.util.Log; + +import java.io.IOException; + +/** + * Convert stream of arbitrary MIDI bytes into discrete messages. + * + * Parses the incoming bytes and then posts individual messages to the receiver + * specified in the constructor. Short messages of 1-3 bytes will be complete. + * System Exclusive messages may be posted in pieces. + * + * Resolves Running Status and interleaved System Real-Time messages. + */ +public class MidiFramer extends MidiReceiver { + private MidiReceiver mReceiver; + private byte[] mBuffer = new byte[3]; + private int mCount; + private byte mRunningStatus; + private int mNeeded; + private boolean mInSysEx; + + public MidiFramer(MidiReceiver receiver) { + mReceiver = receiver; + } + + /* + * @see android.midi.MidiReceiver#onSend(byte[], int, int, long) + */ + @Override + public void onSend(byte[] data, int offset, int count, long timestamp) + throws IOException { + int sysExStartOffset = (mInSysEx ? offset : -1); + + for (int i = 0; i < count; i++) { + final byte currentByte = data[offset]; + final int currentInt = currentByte & 0xFF; + if (currentInt >= 0x80) { // status byte? + if (currentInt < 0xF0) { // channel message? + mRunningStatus = currentByte; + mCount = 1; + mNeeded = MidiConstants.getBytesPerMessage(currentByte) - 1; + } else if (currentInt < 0xF8) { // system common? + if (currentInt == 0xF0 /* SysEx Start */) { + // Log.i(TAG, "SysEx Start"); + mInSysEx = true; + sysExStartOffset = offset; + } else if (currentInt == 0xF7 /* SysEx End */) { + // Log.i(TAG, "SysEx End"); + if (mInSysEx) { + mReceiver.send(data, sysExStartOffset, + offset - sysExStartOffset + 1, timestamp); + mInSysEx = false; + sysExStartOffset = -1; + } + } else { + mBuffer[0] = currentByte; + mRunningStatus = 0; + mCount = 1; + mNeeded = MidiConstants.getBytesPerMessage(currentByte) - 1; + } + } else { // real-time? + // Single byte message interleaved with other data. + if (mInSysEx) { + mReceiver.send(data, sysExStartOffset, + offset - sysExStartOffset, timestamp); + sysExStartOffset = offset + 1; + } + mReceiver.send(data, offset, 1, timestamp); + } + } else { // data byte + if (!mInSysEx) { + mBuffer[mCount++] = currentByte; + if (--mNeeded == 0) { + if (mRunningStatus != 0) { + mBuffer[0] = mRunningStatus; + } + mReceiver.send(mBuffer, 0, mCount, timestamp); + mNeeded = MidiConstants.getBytesPerMessage(mBuffer[0]) - 1; + mCount = 1; + } + } + } + ++offset; + } + + // send any accumulatedSysEx data + if (sysExStartOffset >= 0 && sysExStartOffset < offset) { + mReceiver.send(data, sysExStartOffset, + offset - sysExStartOffset, timestamp); + } + } + +} diff --git a/samples/browseable/MidiSynth/src/com.example.android.common.midi/MidiInputPortSelector.java b/samples/browseable/MidiSynth/src/com.example.android.common.midi/MidiInputPortSelector.java new file mode 100644 index 000000000..7c665bace --- /dev/null +++ b/samples/browseable/MidiSynth/src/com.example.android.common.midi/MidiInputPortSelector.java @@ -0,0 +1,93 @@ +/* + * Copyright (C) 2015 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.example.android.common.midi; + +import android.app.Activity; +import android.media.midi.MidiDevice; +import android.media.midi.MidiDeviceInfo; +import android.media.midi.MidiInputPort; +import android.media.midi.MidiManager; +import android.media.midi.MidiReceiver; +import android.os.Handler; +import android.os.Looper; +import android.util.Log; + +import java.io.IOException; + +/** + * Manages a Spinner for selecting a MidiInputPort. + */ +public class MidiInputPortSelector extends MidiPortSelector { + + private MidiInputPort mInputPort; + private MidiDevice mOpenDevice; + + /** + * @param midiManager + * @param activity + * @param spinnerId ID from the layout resource + */ + public MidiInputPortSelector(MidiManager midiManager, Activity activity, + int spinnerId) { + super(midiManager, activity, spinnerId, MidiDeviceInfo.PortInfo.TYPE_INPUT); + } + + @Override + public void onPortSelected(final MidiPortWrapper wrapper) { + close(); + final MidiDeviceInfo info = wrapper.getDeviceInfo(); + if (info != null) { + mMidiManager.openDevice(info, new MidiManager.OnDeviceOpenedListener() { + @Override + public void onDeviceOpened(MidiDevice device) { + if (device == null) { + Log.e(MidiConstants.TAG, "could not open " + info); + } else { + mOpenDevice = device; + mInputPort = mOpenDevice.openInputPort( + wrapper.getPortIndex()); + if (mInputPort == null) { + Log.e(MidiConstants.TAG, "could not open input port on " + info); + } + } + } + }, null); + // Don't run the callback on the UI thread because openInputPort might take a while. + } + } + + public MidiReceiver getReceiver() { + return mInputPort; + } + + @Override + public void onClose() { + try { + if (mInputPort != null) { + Log.i(MidiConstants.TAG, "MidiInputPortSelector.onClose() - close port"); + mInputPort.close(); + } + mInputPort = null; + if (mOpenDevice != null) { + mOpenDevice.close(); + } + mOpenDevice = null; + } catch (IOException e) { + Log.e(MidiConstants.TAG, "cleanup failed", e); + } + } +} diff --git a/samples/browseable/MidiSynth/src/com.example.android.common.midi/MidiOutputPortConnectionSelector.java b/samples/browseable/MidiSynth/src/com.example.android.common.midi/MidiOutputPortConnectionSelector.java new file mode 100644 index 000000000..ca1ade48c --- /dev/null +++ b/samples/browseable/MidiSynth/src/com.example.android.common.midi/MidiOutputPortConnectionSelector.java @@ -0,0 +1,84 @@ +/* + * Copyright (C) 2015 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.example.android.common.midi; + +import android.app.Activity; +import android.media.midi.MidiDeviceInfo; +import android.media.midi.MidiManager; +import android.util.Log; + +import java.io.IOException; + +/** + * Select an output port and connect it to a destination input port. + */ +public class MidiOutputPortConnectionSelector extends MidiPortSelector { + + private MidiPortConnector mSynthConnector; + private MidiDeviceInfo mDestinationDeviceInfo; + private int mDestinationPortIndex; + private MidiPortConnector.OnPortsConnectedListener mConnectedListener; + + /** + * @param midiManager + * @param activity + * @param spinnerId + * @param type + */ + public MidiOutputPortConnectionSelector(MidiManager midiManager, + Activity activity, int spinnerId, + MidiDeviceInfo destinationDeviceInfo, int destinationPortIndex) { + super(midiManager, activity, spinnerId, + MidiDeviceInfo.PortInfo.TYPE_OUTPUT); + mDestinationDeviceInfo = destinationDeviceInfo; + mDestinationPortIndex = destinationPortIndex; + } + + @Override + public void onPortSelected(final MidiPortWrapper wrapper) { + Log.i(MidiConstants.TAG, "connectPortToSynth: " + wrapper); + onClose(); + if (wrapper.getDeviceInfo() != null) { + mSynthConnector = new MidiPortConnector(mMidiManager); + mSynthConnector.connectToDevicePort(wrapper.getDeviceInfo(), + wrapper.getPortIndex(), mDestinationDeviceInfo, + mDestinationPortIndex, + // not safe on UI thread + mConnectedListener, null); + } + } + + @Override + public void onClose() { + try { + if (mSynthConnector != null) { + mSynthConnector.close(); + mSynthConnector = null; + } + } catch (IOException e) { + Log.e(MidiConstants.TAG, "Exception in closeSynthResources()", e); + } + } + + /** + * @param myPortsConnectedListener + */ + public void setConnectedListener( + MidiPortConnector.OnPortsConnectedListener connectedListener) { + mConnectedListener = connectedListener; + } +} diff --git a/samples/browseable/MidiSynth/src/com.example.android.common.midi/MidiOutputPortSelector.java b/samples/browseable/MidiSynth/src/com.example.android.common.midi/MidiOutputPortSelector.java new file mode 100644 index 000000000..5aebf727e --- /dev/null +++ b/samples/browseable/MidiSynth/src/com.example.android.common.midi/MidiOutputPortSelector.java @@ -0,0 +1,103 @@ +/* + * Copyright (C) 2015 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.example.android.common.midi; + +import android.app.Activity; +import android.media.midi.MidiDevice; +import android.media.midi.MidiDeviceInfo; +import android.media.midi.MidiManager; +import android.media.midi.MidiOutputPort; +import android.media.midi.MidiSender; +import android.os.Handler; +import android.os.Looper; +import android.util.Log; + +import java.io.IOException; + +/** + * Manages a Spinner for selecting a MidiOutputPort. + */ +public class MidiOutputPortSelector extends MidiPortSelector { + private MidiOutputPort mOutputPort; + private MidiDispatcher mDispatcher = new MidiDispatcher(); + private MidiDevice mOpenDevice; + + /** + * @param midiManager + * @param activity + * @param spinnerId ID from the layout resource + */ + public MidiOutputPortSelector(MidiManager midiManager, Activity activity, + int spinnerId) { + super(midiManager, activity, spinnerId, MidiDeviceInfo.PortInfo.TYPE_OUTPUT); + } + + @Override + public void onPortSelected(final MidiPortWrapper wrapper) { + Log.i(MidiConstants.TAG, "onPortSelected: " + wrapper); + close(); + + final MidiDeviceInfo info = wrapper.getDeviceInfo(); + if (info != null) { + mMidiManager.openDevice(info, new MidiManager.OnDeviceOpenedListener() { + + @Override + public void onDeviceOpened(MidiDevice device) { + if (device == null) { + Log.e(MidiConstants.TAG, "could not open " + info); + } else { + mOpenDevice = device; + mOutputPort = device.openOutputPort(wrapper.getPortIndex()); + if (mOutputPort == null) { + Log.e(MidiConstants.TAG, + "could not open output port for " + info); + return; + } + mOutputPort.connect(mDispatcher); + } + } + }, null); + // Don't run the callback on the UI thread because openOutputPort might take a while. + } + } + + @Override + public void onClose() { + try { + if (mOutputPort != null) { + mOutputPort.disconnect(mDispatcher); + } + mOutputPort = null; + if (mOpenDevice != null) { + mOpenDevice.close(); + } + mOpenDevice = null; + } catch (IOException e) { + Log.e(MidiConstants.TAG, "cleanup failed", e); + } + } + + /** + * You can connect your MidiReceivers to this sender. The user will then select which output + * port will send messages through this MidiSender. + * @return a MidiSender that will send the messages from the selected port. + */ + public MidiSender getSender() { + return mDispatcher.getSender(); + } + +} diff --git a/samples/browseable/MidiSynth/src/com.example.android.common.midi/MidiPortConnector.java b/samples/browseable/MidiSynth/src/com.example.android.common.midi/MidiPortConnector.java new file mode 100644 index 000000000..457494d1b --- /dev/null +++ b/samples/browseable/MidiSynth/src/com.example.android.common.midi/MidiPortConnector.java @@ -0,0 +1,200 @@ +/* + * Copyright (C) 2015 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.example.android.common.midi; + +import android.media.midi.MidiDevice; +import android.media.midi.MidiDevice.MidiConnection; +import android.media.midi.MidiDeviceInfo; +import android.media.midi.MidiInputPort; +import android.media.midi.MidiManager; +import android.os.Handler; +import android.util.Log; + +import java.io.IOException; + +/** + * Tool for connecting MIDI ports on two remote devices. + */ +public class MidiPortConnector { + private final MidiManager mMidiManager; + private MidiDevice mSourceDevice; + private MidiDevice mDestinationDevice; + private MidiConnection mConnection; + + /** + * @param mMidiManager + */ + public MidiPortConnector(MidiManager midiManager) { + mMidiManager = midiManager; + } + + public void close() throws IOException { + if (mConnection != null) { + Log.i(MidiConstants.TAG, + "MidiPortConnector closing connection " + mConnection); + mConnection.close(); + mConnection = null; + } + if (mSourceDevice != null) { + mSourceDevice.close(); + mSourceDevice = null; + } + if (mDestinationDevice != null) { + mDestinationDevice.close(); + mDestinationDevice = null; + } + } + + private void safeClose() { + try { + close(); + } catch (IOException e) { + Log.e(MidiConstants.TAG, "could not close resources", e); + } + } + + /** + * Listener class used for receiving the results of + * {@link #connectToDevicePort} + */ + public interface OnPortsConnectedListener { + /** + * Called to respond to a {@link #connectToDevicePort} request + * + * @param connection + * a {@link MidiConnection} that represents the connected + * ports, or null if connection failed + */ + abstract public void onPortsConnected(MidiConnection connection); + } + + /** + * Open two devices and connect their ports. + * + * @param sourceDeviceInfo + * @param sourcePortIndex + * @param destinationDeviceInfo + * @param destinationPortIndex + */ + public void connectToDevicePort(final MidiDeviceInfo sourceDeviceInfo, + final int sourcePortIndex, + final MidiDeviceInfo destinationDeviceInfo, + final int destinationPortIndex) { + connectToDevicePort(sourceDeviceInfo, sourcePortIndex, + destinationDeviceInfo, destinationPortIndex, null, null); + } + + /** + * Open two devices and connect their ports. + * + * @param sourceDeviceInfo + * @param sourcePortIndex + * @param destinationDeviceInfo + * @param destinationPortIndex + */ + public void connectToDevicePort(final MidiDeviceInfo sourceDeviceInfo, + final int sourcePortIndex, + final MidiDeviceInfo destinationDeviceInfo, + final int destinationPortIndex, + final OnPortsConnectedListener listener, final Handler handler) { + safeClose(); + mMidiManager.openDevice(destinationDeviceInfo, + new MidiManager.OnDeviceOpenedListener() { + @Override + public void onDeviceOpened(MidiDevice destinationDevice) { + if (destinationDevice == null) { + Log.e(MidiConstants.TAG, + "could not open " + destinationDeviceInfo); + if (listener != null) { + listener.onPortsConnected(null); + } + } else { + mDestinationDevice = destinationDevice; + Log.i(MidiConstants.TAG, + "connectToDevicePort opened " + + destinationDeviceInfo); + // Destination device was opened so go to next step. + MidiInputPort destinationInputPort = destinationDevice + .openInputPort(destinationPortIndex); + if (destinationInputPort != null) { + Log.i(MidiConstants.TAG, + "connectToDevicePort opened port on " + + destinationDeviceInfo); + connectToDevicePort(sourceDeviceInfo, + sourcePortIndex, + destinationInputPort, + listener, handler); + } else { + Log.e(MidiConstants.TAG, + "could not open port on " + + destinationDeviceInfo); + safeClose(); + if (listener != null) { + listener.onPortsConnected(null); + } + } + } + } + }, handler); + } + + + /** + * Open a source device and connect its output port to the + * destinationInputPort. + * + * @param sourceDeviceInfo + * @param sourcePortIndex + * @param destinationInputPort + */ + private void connectToDevicePort(final MidiDeviceInfo sourceDeviceInfo, + final int sourcePortIndex, + final MidiInputPort destinationInputPort, + final OnPortsConnectedListener listener, final Handler handler) { + mMidiManager.openDevice(sourceDeviceInfo, + new MidiManager.OnDeviceOpenedListener() { + @Override + public void onDeviceOpened(MidiDevice device) { + if (device == null) { + Log.e(MidiConstants.TAG, + "could not open " + sourceDeviceInfo); + safeClose(); + if (listener != null) { + listener.onPortsConnected(null); + } + } else { + Log.i(MidiConstants.TAG, + "connectToDevicePort opened " + + sourceDeviceInfo); + // Device was opened so connect the ports. + mSourceDevice = device; + mConnection = device.connectPorts( + destinationInputPort, sourcePortIndex); + if (mConnection == null) { + Log.e(MidiConstants.TAG, "could not connect to " + + sourceDeviceInfo); + safeClose(); + } + if (listener != null) { + listener.onPortsConnected(mConnection); + } + } + } + }, handler); + } + +} diff --git a/samples/browseable/MidiSynth/src/com.example.android.common.midi/MidiPortSelector.java b/samples/browseable/MidiSynth/src/com.example.android.common.midi/MidiPortSelector.java new file mode 100644 index 000000000..39f983e38 --- /dev/null +++ b/samples/browseable/MidiSynth/src/com.example.android.common.midi/MidiPortSelector.java @@ -0,0 +1,183 @@ +/* + * Copyright (C) 2015 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.example.android.common.midi; + +import android.app.Activity; +import android.media.midi.MidiDeviceInfo; +import android.media.midi.MidiDeviceStatus; +import android.media.midi.MidiManager; +import android.media.midi.MidiManager.DeviceCallback; +import android.os.Handler; +import android.os.Looper; +import android.util.Log; +import android.view.View; +import android.widget.AdapterView; +import android.widget.ArrayAdapter; +import android.widget.Spinner; + +import java.util.HashSet; + +/** + * Base class that uses a Spinner to select available MIDI ports. + */ +public abstract class MidiPortSelector extends DeviceCallback { + private int mType = MidiDeviceInfo.PortInfo.TYPE_INPUT; + protected ArrayAdapter mAdapter; + protected HashSet mBusyPorts = new HashSet(); + private Spinner mSpinner; + protected MidiManager mMidiManager; + protected Activity mActivity; + private MidiPortWrapper mCurrentWrapper; + + /** + * @param midiManager + * @param activity + * @param spinnerId + * ID from the layout resource + * @param type + * TYPE_INPUT or TYPE_OUTPUT + */ + public MidiPortSelector(MidiManager midiManager, Activity activity, + int spinnerId, int type) { + mMidiManager = midiManager; + mActivity = activity; + mType = type; + mAdapter = new ArrayAdapter(activity, + android.R.layout.simple_spinner_item); + mAdapter.setDropDownViewResource( + android.R.layout.simple_spinner_dropdown_item); + mAdapter.add(new MidiPortWrapper(null, 0, 0)); + + mSpinner = (Spinner) activity.findViewById(spinnerId); + mSpinner.setOnItemSelectedListener( + new AdapterView.OnItemSelectedListener() { + + public void onItemSelected(AdapterView parent, View view, + int pos, long id) { + mCurrentWrapper = mAdapter.getItem(pos); + onPortSelected(mCurrentWrapper); + } + + public void onNothingSelected(AdapterView parent) { + onPortSelected(null); + mCurrentWrapper = null; + } + }); + mSpinner.setAdapter(mAdapter); + + mMidiManager.registerDeviceCallback(this, + new Handler(Looper.getMainLooper())); + + MidiDeviceInfo[] infos = mMidiManager.getDevices(); + for (MidiDeviceInfo info : infos) { + onDeviceAdded(info); + } + } + + /** + * Set to no port selected. + */ + public void clearSelection() { + mSpinner.setSelection(0); + } + + private int getInfoPortCount(final MidiDeviceInfo info) { + int portCount = (mType == MidiDeviceInfo.PortInfo.TYPE_INPUT) + ? info.getInputPortCount() : info.getOutputPortCount(); + return portCount; + } + + @Override + public void onDeviceAdded(final MidiDeviceInfo info) { + int portCount = getInfoPortCount(info); + for (int i = 0; i < portCount; ++i) { + MidiPortWrapper wrapper = new MidiPortWrapper(info, mType, i); + mAdapter.add(wrapper); + Log.i(MidiConstants.TAG, wrapper + " was added"); + mAdapter.notifyDataSetChanged(); + } + } + + @Override + public void onDeviceRemoved(final MidiDeviceInfo info) { + int portCount = getInfoPortCount(info); + for (int i = 0; i < portCount; ++i) { + MidiPortWrapper wrapper = new MidiPortWrapper(info, mType, i); + MidiPortWrapper currentWrapper = mCurrentWrapper; + mAdapter.remove(wrapper); + // If the currently selected port was removed then select no port. + if (wrapper.equals(currentWrapper)) { + clearSelection(); + } + mAdapter.notifyDataSetChanged(); + Log.i(MidiConstants.TAG, wrapper + " was removed"); + } + } + + @Override + public void onDeviceStatusChanged(final MidiDeviceStatus status) { + // If an input port becomes busy then remove it from the menu. + // If it becomes free then add it back to the menu. + if (mType == MidiDeviceInfo.PortInfo.TYPE_INPUT) { + MidiDeviceInfo info = status.getDeviceInfo(); + Log.i(MidiConstants.TAG, "MidiPortSelector.onDeviceStatusChanged status = " + status + + ", mType = " + mType + + ", activity = " + mActivity.getPackageName() + + ", info = " + info); + // Look for transitions from free to busy. + int portCount = info.getInputPortCount(); + for (int i = 0; i < portCount; ++i) { + MidiPortWrapper wrapper = new MidiPortWrapper(info, mType, i); + if (!wrapper.equals(mCurrentWrapper)) { + if (status.isInputPortOpen(i)) { // busy? + if (!mBusyPorts.contains(wrapper)) { + // was free, now busy + mBusyPorts.add(wrapper); + mAdapter.remove(wrapper); + mAdapter.notifyDataSetChanged(); + } + } else { + if (mBusyPorts.remove(wrapper)) { + // was busy, now free + mAdapter.add(wrapper); + mAdapter.notifyDataSetChanged(); + } + } + } + } + } + } + + /** + * Implement this method to handle the user selecting a port on a device. + * + * @param wrapper + */ + public abstract void onPortSelected(MidiPortWrapper wrapper); + + /** + * Implement this method to clean up any open resources. + */ + public abstract void onClose(); + + /** + * + */ + public void close() { + onClose(); + } +} diff --git a/samples/browseable/MidiSynth/src/com.example.android.common.midi/MidiPortWrapper.java b/samples/browseable/MidiSynth/src/com.example.android.common.midi/MidiPortWrapper.java new file mode 100644 index 000000000..77aa73458 --- /dev/null +++ b/samples/browseable/MidiSynth/src/com.example.android.common.midi/MidiPortWrapper.java @@ -0,0 +1,125 @@ +/* + * Copyright (C) 2015 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.example.android.common.midi; + +import android.media.midi.MidiDeviceInfo; +import android.media.midi.MidiDeviceInfo.PortInfo; +import android.util.Log; + +// Wrapper for a MIDI device and port description. +public class MidiPortWrapper { + private MidiDeviceInfo mInfo; + private int mPortIndex; + private int mType; + private String mString; + + /** + * Wrapper for a MIDI device and port description. + * @param info + * @param portType + * @param portIndex + */ + public MidiPortWrapper(MidiDeviceInfo info, int portType, int portIndex) { + mInfo = info; + mType = portType; + mPortIndex = portIndex; + } + + private void updateString() { + if (mInfo == null) { + mString = "- - - - - -"; + } else { + StringBuilder sb = new StringBuilder(); + String name = mInfo.getProperties() + .getString(MidiDeviceInfo.PROPERTY_NAME); + if (name == null) { + name = mInfo.getProperties() + .getString(MidiDeviceInfo.PROPERTY_MANUFACTURER) + ", " + + mInfo.getProperties() + .getString(MidiDeviceInfo.PROPERTY_PRODUCT); + } + sb.append("#" + mInfo.getId()); + sb.append(", ").append(name); + PortInfo portInfo = findPortInfo(); + sb.append("[" + mPortIndex + "]"); + if (portInfo != null) { + sb.append(", ").append(portInfo.getName()); + } else { + sb.append(", null"); + } + mString = sb.toString(); + } + } + + /** + * @param info + * @param portIndex + * @return + */ + private PortInfo findPortInfo() { + PortInfo[] ports = mInfo.getPorts(); + for (PortInfo portInfo : ports) { + if (portInfo.getPortNumber() == mPortIndex + && portInfo.getType() == mType) { + return portInfo; + } + } + return null; + } + + public int getPortIndex() { + return mPortIndex; + } + + public MidiDeviceInfo getDeviceInfo() { + return mInfo; + } + + @Override + public String toString() { + if (mString == null) { + updateString(); + } + return mString; + } + + @Override + public boolean equals(Object other) { + if (other == null) + return false; + if (!(other instanceof MidiPortWrapper)) + return false; + MidiPortWrapper otherWrapper = (MidiPortWrapper) other; + if (mPortIndex != otherWrapper.mPortIndex) + return false; + if (mType != otherWrapper.mType) + return false; + if (mInfo == null) + return (otherWrapper.mInfo == null); + return mInfo.equals(otherWrapper.mInfo); + } + + @Override + public int hashCode() { + int hashCode = 1; + hashCode = 31 * hashCode + mPortIndex; + hashCode = 31 * hashCode + mType; + hashCode = 31 * hashCode + mInfo.hashCode(); + return hashCode; + } + +} diff --git a/samples/browseable/MidiSynth/src/com.example.android.common.midi/MidiTools.java b/samples/browseable/MidiSynth/src/com.example.android.common.midi/MidiTools.java new file mode 100644 index 000000000..82e3de4ba --- /dev/null +++ b/samples/browseable/MidiSynth/src/com.example.android.common.midi/MidiTools.java @@ -0,0 +1,46 @@ +/* + * Copyright (C) 2015 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.example.android.common.midi; + +import android.media.midi.MidiDeviceInfo; +import android.media.midi.MidiManager; + +/** + * Miscellaneous tools for Android MIDI. + */ +public class MidiTools { + + /** + * @return a device that matches the manufacturer and product or null + */ + public static MidiDeviceInfo findDevice(MidiManager midiManager, + String manufacturer, String product) { + for (MidiDeviceInfo info : midiManager.getDevices()) { + String deviceManufacturer = info.getProperties() + .getString(MidiDeviceInfo.PROPERTY_MANUFACTURER); + if ((manufacturer != null) + && manufacturer.equals(deviceManufacturer)) { + String deviceProduct = info.getProperties() + .getString(MidiDeviceInfo.PROPERTY_PRODUCT); + if ((product != null) && product.equals(deviceProduct)) { + return info; + } + } + } + return null; + } +} diff --git a/samples/browseable/MidiSynth/src/com.example.android.common.midi/synth/EnvelopeADSR.java b/samples/browseable/MidiSynth/src/com.example.android.common.midi/synth/EnvelopeADSR.java new file mode 100644 index 000000000..a29a1933e --- /dev/null +++ b/samples/browseable/MidiSynth/src/com.example.android.common.midi/synth/EnvelopeADSR.java @@ -0,0 +1,111 @@ +/* + * Copyright (C) 2015 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.example.android.common.midi.synth; + +/** + * Very simple Attack, Decay, Sustain, Release envelope with linear ramps. + * + * Times are in seconds. + */ +public class EnvelopeADSR extends SynthUnit { + private static final int IDLE = 0; + private static final int ATTACK = 1; + private static final int DECAY = 2; + private static final int SUSTAIN = 3; + private static final int RELEASE = 4; + private static final int FINISHED = 5; + private static final float MIN_TIME = 0.001f; + + private float mAttackRate; + private float mRreleaseRate; + private float mSustainLevel; + private float mDecayRate; + private float mCurrent; + private int mSstate = IDLE; + + public EnvelopeADSR() { + setAttackTime(0.003f); + setDecayTime(0.08f); + setSustainLevel(0.3f); + setReleaseTime(1.0f); + } + + public void setAttackTime(float time) { + if (time < MIN_TIME) + time = MIN_TIME; + mAttackRate = 1.0f / (SynthEngine.FRAME_RATE * time); + } + + public void setDecayTime(float time) { + if (time < MIN_TIME) + time = MIN_TIME; + mDecayRate = 1.0f / (SynthEngine.FRAME_RATE * time); + } + + public void setSustainLevel(float level) { + if (level < 0.0f) + level = 0.0f; + mSustainLevel = level; + } + + public void setReleaseTime(float time) { + if (time < MIN_TIME) + time = MIN_TIME; + mRreleaseRate = 1.0f / (SynthEngine.FRAME_RATE * time); + } + + public void on() { + mSstate = ATTACK; + } + + public void off() { + mSstate = RELEASE; + } + + @Override + public float render() { + switch (mSstate) { + case ATTACK: + mCurrent += mAttackRate; + if (mCurrent > 1.0f) { + mCurrent = 1.0f; + mSstate = DECAY; + } + break; + case DECAY: + mCurrent -= mDecayRate; + if (mCurrent < mSustainLevel) { + mCurrent = mSustainLevel; + mSstate = SUSTAIN; + } + break; + case RELEASE: + mCurrent -= mRreleaseRate; + if (mCurrent < 0.0f) { + mCurrent = 0.0f; + mSstate = FINISHED; + } + break; + } + return mCurrent; + } + + public boolean isDone() { + return mSstate == FINISHED; + } + +} diff --git a/samples/browseable/MidiSynth/src/com.example.android.common.midi/synth/SawOscillator.java b/samples/browseable/MidiSynth/src/com.example.android.common.midi/synth/SawOscillator.java new file mode 100644 index 000000000..c02a6a1a5 --- /dev/null +++ b/samples/browseable/MidiSynth/src/com.example.android.common.midi/synth/SawOscillator.java @@ -0,0 +1,73 @@ +/* + * Copyright (C) 2015 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.example.android.common.midi.synth; + +public class SawOscillator extends SynthUnit { + private float mPhase = 0.0f; + private float mPhaseIncrement = 0.01f; + private float mFrequency = 0.0f; + private float mFrequencyScaler = 1.0f; + private float mAmplitude = 1.0f; + + public void setPitch(float pitch) { + float freq = (float) pitchToFrequency(pitch); + setFrequency(freq); + } + + public void setFrequency(float frequency) { + mFrequency = frequency; + updatePhaseIncrement(); + } + + private void updatePhaseIncrement() { + mPhaseIncrement = 2.0f * mFrequency * mFrequencyScaler / 48000.0f; + } + + public void setAmplitude(float amplitude) { + mAmplitude = amplitude; + } + + public float getAmplitude() { + return mAmplitude; + } + + public float getFrequencyScaler() { + return mFrequencyScaler; + } + + public void setFrequencyScaler(float frequencyScaler) { + mFrequencyScaler = frequencyScaler; + updatePhaseIncrement(); + } + + float incrementWrapPhase() { + mPhase += mPhaseIncrement; + while (mPhase > 1.0) { + mPhase -= 2.0; + } + while (mPhase < -1.0) { + mPhase += 2.0; + } + return mPhase; + } + + @Override + public float render() { + return incrementWrapPhase() * mAmplitude; + } + +} diff --git a/samples/browseable/MidiSynth/src/com.example.android.common.midi/synth/SawOscillatorDPW.java b/samples/browseable/MidiSynth/src/com.example.android.common.midi/synth/SawOscillatorDPW.java new file mode 100644 index 000000000..e5d661d56 --- /dev/null +++ b/samples/browseable/MidiSynth/src/com.example.android.common.midi/synth/SawOscillatorDPW.java @@ -0,0 +1,52 @@ +/* + * Copyright (C) 2015 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.example.android.common.midi.synth; + +/** + * Band limited sawtooth oscillator. + * This will have very little aliasing at high frequencies. + */ +public class SawOscillatorDPW extends SawOscillator { + private float mZ1 = 0.0f; // delayed values + private float mZ2 = 0.0f; + private float mScaler; // frequency dependent scaler + private final static float VERY_LOW_FREQ = 0.0000001f; + + @Override + public void setFrequency(float freq) { + /* Calculate scaling based on frequency. */ + freq = Math.abs(freq); + super.setFrequency(freq); + if (freq < VERY_LOW_FREQ) { + mScaler = (float) (0.125 * 44100 / VERY_LOW_FREQ); + } else { + mScaler = (float) (0.125 * 44100 / freq); + } + } + + @Override + public float render() { + float phase = incrementWrapPhase(); + /* Square the raw sawtooth. */ + float squared = phase * phase; + float diffed = squared - mZ2; + mZ2 = mZ1; + mZ1 = squared; + return diffed * mScaler * getAmplitude(); + } + +} diff --git a/samples/browseable/MidiSynth/src/com.example.android.common.midi/synth/SawVoice.java b/samples/browseable/MidiSynth/src/com.example.android.common.midi/synth/SawVoice.java new file mode 100644 index 000000000..3b3e543e8 --- /dev/null +++ b/samples/browseable/MidiSynth/src/com.example.android.common.midi/synth/SawVoice.java @@ -0,0 +1,65 @@ +/* + * Copyright (C) 2015 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.example.android.common.midi.synth; + +/** + * Sawtooth oscillator with an ADSR. + */ +public class SawVoice extends SynthVoice { + private SawOscillator mOscillator; + private EnvelopeADSR mEnvelope; + + public SawVoice() { + mOscillator = createOscillator(); + mEnvelope = new EnvelopeADSR(); + } + + protected SawOscillator createOscillator() { + return new SawOscillator(); + } + + @Override + public void noteOn(int noteIndex, int velocity) { + super.noteOn(noteIndex, velocity); + mOscillator.setPitch(noteIndex); + mOscillator.setAmplitude(getAmplitude()); + mEnvelope.on(); + } + + @Override + public void noteOff() { + super.noteOff(); + mEnvelope.off(); + } + + @Override + public void setFrequencyScaler(float scaler) { + mOscillator.setFrequencyScaler(scaler); + } + + @Override + public float render() { + float output = mOscillator.render() * mEnvelope.render(); + return output; + } + + @Override + public boolean isDone() { + return mEnvelope.isDone(); + } + +} diff --git a/samples/browseable/MidiSynth/src/com.example.android.common.midi/synth/SimpleAudioOutput.java b/samples/browseable/MidiSynth/src/com.example.android.common.midi/synth/SimpleAudioOutput.java new file mode 100644 index 000000000..04aa19c0b --- /dev/null +++ b/samples/browseable/MidiSynth/src/com.example.android.common.midi/synth/SimpleAudioOutput.java @@ -0,0 +1,95 @@ +/* + * Copyright (C) 2015 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.example.android.common.midi.synth; + +import android.media.AudioFormat; +import android.media.AudioManager; +import android.media.AudioTrack; +import android.util.Log; + +/** + * Simple base class for implementing audio output for examples. + * This can be sub-classed for experimentation or to redirect audio output. + */ +public class SimpleAudioOutput { + + private static final String TAG = "AudioOutputTrack"; + public static final int SAMPLES_PER_FRAME = 2; + public static final int BYTES_PER_SAMPLE = 4; // float + public static final int BYTES_PER_FRAME = SAMPLES_PER_FRAME * BYTES_PER_SAMPLE; + private AudioTrack mAudioTrack; + private int mFrameRate; + + /** + * + */ + public SimpleAudioOutput() { + super(); + } + + /** + * Create an audio track then call play(). + * + * @param frameRate + */ + public void start(int frameRate) { + stop(); + mFrameRate = frameRate; + mAudioTrack = createAudioTrack(frameRate); + // AudioTrack will wait until it has enough data before starting. + mAudioTrack.play(); + } + + public AudioTrack createAudioTrack(int frameRate) { + int minBufferSizeBytes = AudioTrack.getMinBufferSize(frameRate, + AudioFormat.CHANNEL_OUT_STEREO, AudioFormat.ENCODING_PCM_FLOAT); + Log.i(TAG, "AudioTrack.minBufferSize = " + minBufferSizeBytes + + " bytes = " + (minBufferSizeBytes / BYTES_PER_FRAME) + + " frames"); + int bufferSize = 8 * minBufferSizeBytes / 8; + int outputBufferSizeFrames = bufferSize / BYTES_PER_FRAME; + Log.i(TAG, "actual bufferSize = " + bufferSize + " bytes = " + + outputBufferSizeFrames + " frames"); + + AudioTrack player = new AudioTrack(AudioManager.STREAM_MUSIC, + mFrameRate, AudioFormat.CHANNEL_OUT_STEREO, + AudioFormat.ENCODING_PCM_FLOAT, bufferSize, + AudioTrack.MODE_STREAM); + Log.i(TAG, "created AudioTrack"); + return player; + } + + public int write(float[] buffer, int offset, int length) { + return mAudioTrack.write(buffer, offset, length, + AudioTrack.WRITE_BLOCKING); + } + + public void stop() { + if (mAudioTrack != null) { + mAudioTrack.stop(); + mAudioTrack = null; + } + } + + public int getFrameRate() { + return mFrameRate; + } + + public AudioTrack getAudioTrack() { + return mAudioTrack; + } +} diff --git a/samples/browseable/MidiSynth/src/com.example.android.common.midi/synth/SineOscillator.java b/samples/browseable/MidiSynth/src/com.example.android.common.midi/synth/SineOscillator.java new file mode 100644 index 000000000..c638c344c --- /dev/null +++ b/samples/browseable/MidiSynth/src/com.example.android.common.midi/synth/SineOscillator.java @@ -0,0 +1,54 @@ +/* + * Copyright (C) 2015 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.example.android.common.midi.synth; + +/** + * Sinewave oscillator. + */ +public class SineOscillator extends SawOscillator { + // Factorial constants. + private static final float IF3 = 1.0f / (2 * 3); + private static final float IF5 = IF3 / (4 * 5); + private static final float IF7 = IF5 / (6 * 7); + private static final float IF9 = IF7 / (8 * 9); + private static final float IF11 = IF9 / (10 * 11); + + /** + * Calculate sine using Taylor expansion. Do not use values outside the range. + * + * @param currentPhase in the range of -1.0 to +1.0 for one cycle + */ + public static float fastSin(float currentPhase) { + + /* Wrap phase back into region where results are more accurate. */ + float yp = (currentPhase > 0.5f) ? 1.0f - currentPhase + : ((currentPhase < (-0.5f)) ? (-1.0f) - currentPhase : currentPhase); + + float x = (float) (yp * Math.PI); + float x2 = (x * x); + /* Taylor expansion out to x**11/11! factored into multiply-adds */ + return x * (x2 * (x2 * (x2 * (x2 * ((x2 * (-IF11)) + IF9) - IF7) + IF5) - IF3) + 1); + } + + @Override + public float render() { + // Convert raw sawtooth to sine. + float phase = incrementWrapPhase(); + return fastSin(phase) * getAmplitude(); + } + +} diff --git a/samples/browseable/MidiSynth/src/com.example.android.common.midi/synth/SineVoice.java b/samples/browseable/MidiSynth/src/com.example.android.common.midi/synth/SineVoice.java new file mode 100644 index 000000000..e80d2c7eb --- /dev/null +++ b/samples/browseable/MidiSynth/src/com.example.android.common.midi/synth/SineVoice.java @@ -0,0 +1,27 @@ +/* + * Copyright (C) 2015 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.example.android.common.midi.synth; + +/** + * Replace sawtooth with a sine wave. + */ +public class SineVoice extends SawVoice { + @Override + protected SawOscillator createOscillator() { + return new SineOscillator(); + } +} diff --git a/samples/browseable/MidiSynth/src/com.example.android.common.midi/synth/SynthEngine.java b/samples/browseable/MidiSynth/src/com.example.android.common.midi/synth/SynthEngine.java new file mode 100644 index 000000000..6cd02a609 --- /dev/null +++ b/samples/browseable/MidiSynth/src/com.example.android.common.midi/synth/SynthEngine.java @@ -0,0 +1,284 @@ +/* + * Copyright (C) 2015 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.example.android.common.midi.synth; + +import android.media.midi.MidiReceiver; +import android.util.Log; + +import com.example.android.common.midi.MidiConstants; +import com.example.android.common.midi.MidiEventScheduler; +import com.example.android.common.midi.MidiEventScheduler.MidiEvent; +import com.example.android.common.midi.MidiFramer; + +import java.io.IOException; +import java.util.ArrayList; +import java.util.Hashtable; +import java.util.Iterator; + +/** + * Very simple polyphonic, single channel synthesizer. It runs a background + * thread that processes MIDI events and synthesizes audio. + */ +public class SynthEngine extends MidiReceiver { + + private static final String TAG = "SynthEngine"; + + public static final int FRAME_RATE = 48000; + private static final int FRAMES_PER_BUFFER = 240; + private static final int SAMPLES_PER_FRAME = 2; + + private boolean go; + private Thread mThread; + private float[] mBuffer = new float[FRAMES_PER_BUFFER * SAMPLES_PER_FRAME]; + private float mFrequencyScaler = 1.0f; + private float mBendRange = 2.0f; // semitones + private int mProgram; + + private ArrayList mFreeVoices = new ArrayList(); + private Hashtable + mVoices = new Hashtable(); + private MidiEventScheduler mEventScheduler; + private MidiFramer mFramer; + private MidiReceiver mReceiver = new MyReceiver(); + private SimpleAudioOutput mAudioOutput; + + public SynthEngine() { + this(new SimpleAudioOutput()); + } + + public SynthEngine(SimpleAudioOutput audioOutput) { + mReceiver = new MyReceiver(); + mFramer = new MidiFramer(mReceiver); + mAudioOutput = audioOutput; + } + + @Override + public void onSend(byte[] data, int offset, int count, long timestamp) + throws IOException { + if (mEventScheduler != null) { + if (!MidiConstants.isAllActiveSensing(data, offset, count)) { + mEventScheduler.getReceiver().send(data, offset, count, + timestamp); + } + } + } + + private class MyReceiver extends MidiReceiver { + @Override + public void onSend(byte[] data, int offset, int count, long timestamp) + throws IOException { + byte command = (byte) (data[0] & MidiConstants.STATUS_COMMAND_MASK); + int channel = (byte) (data[0] & MidiConstants.STATUS_CHANNEL_MASK); + switch (command) { + case MidiConstants.STATUS_NOTE_OFF: + noteOff(channel, data[1], data[2]); + break; + case MidiConstants.STATUS_NOTE_ON: + noteOn(channel, data[1], data[2]); + break; + case MidiConstants.STATUS_PITCH_BEND: + int bend = (data[2] << 7) + data[1]; + pitchBend(channel, bend); + break; + case MidiConstants.STATUS_PROGRAM_CHANGE: + mProgram = data[1]; + mFreeVoices.clear(); + break; + default: + logMidiMessage(data, offset, count); + break; + } + } + } + + class MyRunnable implements Runnable { + @Override + public void run() { + try { + mAudioOutput.start(FRAME_RATE); + onLoopStarted(); + while (go) { + processMidiEvents(); + generateBuffer(); + mAudioOutput.write(mBuffer, 0, mBuffer.length); + onBufferCompleted(FRAMES_PER_BUFFER); + } + } catch (Exception e) { + Log.e(TAG, "SynthEngine background thread exception.", e); + } finally { + onLoopEnded(); + mAudioOutput.stop(); + } + } + } + + /** + * This is called form the synthesis thread before it starts looping. + */ + public void onLoopStarted() { + } + + /** + * This is called once at the end of each synthesis loop. + * + * @param framesPerBuffer + */ + public void onBufferCompleted(int framesPerBuffer) { + } + + /** + * This is called form the synthesis thread when it stop looping. + */ + public void onLoopEnded() { + } + + /** + * Assume message has been aligned to the start of a MIDI message. + * + * @param data + * @param offset + * @param count + */ + public void logMidiMessage(byte[] data, int offset, int count) { + String text = "Received: "; + for (int i = 0; i < count; i++) { + text += String.format("0x%02X, ", data[offset + i]); + } + Log.i(TAG, text); + } + + /** + * @throws IOException + * + */ + private void processMidiEvents() throws IOException { + long now = System.nanoTime(); // TODO use audio presentation time + MidiEvent event = (MidiEvent) mEventScheduler.getNextEvent(now); + while (event != null) { + mFramer.send(event.data, 0, event.count, event.getTimestamp()); + mEventScheduler.addEventToPool(event); + event = (MidiEvent) mEventScheduler.getNextEvent(now); + } + } + + /** + * + */ + private void generateBuffer() { + for (int i = 0; i < mBuffer.length; i++) { + mBuffer[i] = 0.0f; + } + Iterator iterator = mVoices.values().iterator(); + while (iterator.hasNext()) { + SynthVoice voice = iterator.next(); + if (voice.isDone()) { + iterator.remove(); + // mFreeVoices.add(voice); + } else { + voice.mix(mBuffer, SAMPLES_PER_FRAME, 0.25f); + } + } + } + + public void noteOff(int channel, int noteIndex, int velocity) { + SynthVoice voice = mVoices.get(noteIndex); + if (voice != null) { + voice.noteOff(); + } + } + + public void allNotesOff() { + Iterator iterator = mVoices.values().iterator(); + while (iterator.hasNext()) { + SynthVoice voice = iterator.next(); + voice.noteOff(); + } + } + + /** + * Create a SynthVoice. + */ + public SynthVoice createVoice(int program) { + // For every odd program number use a sine wave. + if ((program & 1) == 1) { + return new SineVoice(); + } else { + return new SawVoice(); + } + } + + /** + * + * @param channel + * @param noteIndex + * @param velocity + */ + public void noteOn(int channel, int noteIndex, int velocity) { + if (velocity == 0) { + noteOff(channel, noteIndex, velocity); + } else { + mVoices.remove(noteIndex); + SynthVoice voice; + if (mFreeVoices.size() > 0) { + voice = mFreeVoices.remove(mFreeVoices.size() - 1); + } else { + voice = createVoice(mProgram); + } + voice.setFrequencyScaler(mFrequencyScaler); + voice.noteOn(noteIndex, velocity); + mVoices.put(noteIndex, voice); + } + } + + public void pitchBend(int channel, int bend) { + double semitones = (mBendRange * (bend - 0x2000)) / 0x2000; + mFrequencyScaler = (float) Math.pow(2.0, semitones / 12.0); + Iterator iterator = mVoices.values().iterator(); + while (iterator.hasNext()) { + SynthVoice voice = iterator.next(); + voice.setFrequencyScaler(mFrequencyScaler); + } + } + + /** + * Start the synthesizer. + */ + public void start() { + stop(); + go = true; + mThread = new Thread(new MyRunnable()); + mEventScheduler = new MidiEventScheduler(); + mThread.start(); + } + + /** + * Stop the synthesizer. + */ + public void stop() { + go = false; + if (mThread != null) { + try { + mThread.interrupt(); + mThread.join(500); + } catch (InterruptedException e) { + // OK, just stopping safely. + } + mThread = null; + mEventScheduler = null; + } + } +} diff --git a/samples/browseable/MidiSynth/src/com.example.android.common.midi/synth/SynthUnit.java b/samples/browseable/MidiSynth/src/com.example.android.common.midi/synth/SynthUnit.java new file mode 100644 index 000000000..90599e284 --- /dev/null +++ b/samples/browseable/MidiSynth/src/com.example.android.common.midi/synth/SynthUnit.java @@ -0,0 +1,35 @@ +/* + * Copyright (C) 2015 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.example.android.common.midi.synth; + +public abstract class SynthUnit { + + private static final double CONCERT_A_PITCH = 69.0; + private static final double CONCERT_A_FREQUENCY = 440.0; + + /** + * @param pitch + * MIDI pitch in semitones + * @return frequency + */ + public static double pitchToFrequency(double pitch) { + double semitones = pitch - CONCERT_A_PITCH; + return CONCERT_A_FREQUENCY * Math.pow(2.0, semitones / 12.0); + } + + public abstract float render(); +} diff --git a/samples/browseable/MidiSynth/src/com.example.android.common.midi/synth/SynthVoice.java b/samples/browseable/MidiSynth/src/com.example.android.common.midi/synth/SynthVoice.java new file mode 100644 index 000000000..78ba09ac4 --- /dev/null +++ b/samples/browseable/MidiSynth/src/com.example.android.common.midi/synth/SynthVoice.java @@ -0,0 +1,85 @@ +/* + * Copyright (C) 2015 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.example.android.common.midi.synth; + +/** + * Base class for a polyphonic synthesizer voice. + */ +public abstract class SynthVoice { + private int mNoteIndex; + private float mAmplitude; + public static final int STATE_OFF = 0; + public static final int STATE_ON = 1; + private int mState = STATE_OFF; + + public SynthVoice() { + mNoteIndex = -1; + } + + public void noteOn(int noteIndex, int velocity) { + mState = STATE_ON; + this.mNoteIndex = noteIndex; + setAmplitude(velocity / 128.0f); + } + + public void noteOff() { + mState = STATE_OFF; + } + + /** + * Add the output of this voice to an output buffer. + * + * @param outputBuffer + * @param samplesPerFrame + * @param level + */ + public void mix(float[] outputBuffer, int samplesPerFrame, float level) { + int numFrames = outputBuffer.length / samplesPerFrame; + for (int i = 0; i < numFrames; i++) { + float output = render(); + int offset = i * samplesPerFrame; + for (int jf = 0; jf < samplesPerFrame; jf++) { + outputBuffer[offset + jf] += output * level; + } + } + } + + public abstract float render(); + + public boolean isDone() { + return mState == STATE_OFF; + } + + public int getNoteIndex() { + return mNoteIndex; + } + + public float getAmplitude() { + return mAmplitude; + } + + public void setAmplitude(float amplitude) { + this.mAmplitude = amplitude; + } + + /** + * @param scaler + */ + public void setFrequencyScaler(float scaler) { + } + +} diff --git a/samples/browseable/MidiSynth/src/com.example.android.midisynth/MainActivity.java b/samples/browseable/MidiSynth/src/com.example.android.midisynth/MainActivity.java new file mode 100644 index 000000000..92964b483 --- /dev/null +++ b/samples/browseable/MidiSynth/src/com.example.android.midisynth/MainActivity.java @@ -0,0 +1,135 @@ +/* + * Copyright (C) 2015 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.example.android.midisynth; + +import android.app.ActionBar; +import android.app.Activity; +import android.content.pm.PackageManager; +import android.media.midi.MidiDevice.MidiConnection; +import android.media.midi.MidiDeviceInfo; +import android.media.midi.MidiManager; +import android.os.Bundle; +import android.view.Menu; +import android.view.MenuItem; +import android.view.WindowManager; +import android.widget.Toast; +import android.widget.Toolbar; + +import com.example.android.common.midi.MidiOutputPortConnectionSelector; +import com.example.android.common.midi.MidiPortConnector; +import com.example.android.common.midi.MidiTools; + +/** + * Simple synthesizer as a MIDI Device. + */ +public class MainActivity extends Activity { + static final String TAG = "MidiSynthExample"; + + private MidiManager mMidiManager; + private MidiOutputPortConnectionSelector mPortSelector; + + @Override + public void onCreate(Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + setContentView(R.layout.main); + setActionBar((Toolbar) findViewById(R.id.toolbar)); + ActionBar actionBar = getActionBar(); + if (actionBar != null) { + actionBar.setDisplayShowTitleEnabled(false); + } + + if (getPackageManager().hasSystemFeature(PackageManager.FEATURE_MIDI)) { + setupMidi(); + } + } + + @Override + public boolean onCreateOptionsMenu(Menu menu) { + getMenuInflater().inflate(R.menu.main, menu); + setKeepScreenOn(menu.findItem(R.id.action_keep_screen_on).isChecked()); + return true; + } + + @Override + public boolean onOptionsItemSelected(MenuItem item) { + switch (item.getItemId()) { + case R.id.action_keep_screen_on: + boolean checked = !item.isChecked(); + setKeepScreenOn(checked); + item.setChecked(checked); + break; + } + return super.onOptionsItemSelected(item); + } + + private void setKeepScreenOn(boolean keepScreenOn) { + if (keepScreenOn) { + getWindow().addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON); + } else { + getWindow().clearFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON); + } + } + + private void setupMidi() { + // Setup MIDI + mMidiManager = (MidiManager) getSystemService(MIDI_SERVICE); + + MidiDeviceInfo synthInfo = MidiTools.findDevice(mMidiManager, "AndroidTest", + "SynthExample"); + int portIndex = 0; + mPortSelector = new MidiOutputPortConnectionSelector(mMidiManager, this, + R.id.spinner_synth_sender, synthInfo, portIndex); + mPortSelector.setConnectedListener(new MyPortsConnectedListener()); + } + + private void closeSynthResources() { + if (mPortSelector != null) { + mPortSelector.close(); + } + } + + // TODO A better way would be to listen to the synth server + // for open/close events and then disable/enable the spinner. + private class MyPortsConnectedListener + implements MidiPortConnector.OnPortsConnectedListener { + @Override + public void onPortsConnected(final MidiConnection connection) { + runOnUiThread(new Runnable() { + @Override + public void run() { + if (connection == null) { + Toast.makeText(MainActivity.this, + R.string.error_port_busy, Toast.LENGTH_SHORT) + .show(); + mPortSelector.clearSelection(); + } else { + Toast.makeText(MainActivity.this, + R.string.port_open_ok, Toast.LENGTH_SHORT) + .show(); + } + } + }); + } + } + + @Override + public void onDestroy() { + closeSynthResources(); + super.onDestroy(); + } + +} diff --git a/samples/browseable/MidiSynth/src/com.example.android.midisynth/MidiSynthDeviceService.java b/samples/browseable/MidiSynth/src/com.example.android.midisynth/MidiSynthDeviceService.java new file mode 100644 index 000000000..b9f25eeb7 --- /dev/null +++ b/samples/browseable/MidiSynth/src/com.example.android.midisynth/MidiSynthDeviceService.java @@ -0,0 +1,61 @@ +/* + * Copyright (C) 2015 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.example.android.midisynth; + +import android.media.midi.MidiDeviceService; +import android.media.midi.MidiDeviceStatus; +import android.media.midi.MidiReceiver; + +import com.example.android.common.midi.synth.SynthEngine; + +public class MidiSynthDeviceService extends MidiDeviceService { + + private static final String TAG = MainActivity.TAG; + private SynthEngine mSynthEngine = new SynthEngine(); + private boolean mSynthStarted = false; + + @Override + public void onCreate() { + super.onCreate(); + } + + @Override + public void onDestroy() { + mSynthEngine.stop(); + super.onDestroy(); + } + + @Override + public MidiReceiver[] onGetInputPortReceivers() { + return new MidiReceiver[]{mSynthEngine}; + } + + /** + * This will get called when clients connect or disconnect. + */ + @Override + public void onDeviceStatusChanged(MidiDeviceStatus status) { + if (status.isInputPortOpen(0) && !mSynthStarted) { + mSynthEngine.start(); + mSynthStarted = true; + } else if (!status.isInputPortOpen(0) && mSynthStarted) { + mSynthEngine.stop(); + mSynthStarted = false; + } + } + +} diff --git a/samples/browseable/NavigationDrawer/res/layout/activity_navigation_drawer.xml b/samples/browseable/NavigationDrawer/res/layout/activity_navigation_drawer.xml index 4e61639a8..673ef6309 100644 --- a/samples/browseable/NavigationDrawer/res/layout/activity_navigation_drawer.xml +++ b/samples/browseable/NavigationDrawer/res/layout/activity_navigation_drawer.xml @@ -19,9 +19,12 @@ + android:layout_height="match_parent" + tools:openDrawer="start" > @@ -44,5 +47,6 @@ android:layout_gravity="left|start" android:choiceMode="singleChoice" android:divider="@null" + app:layoutManager="LinearLayoutManager" /> - \ No newline at end of file + diff --git a/samples/browseable/NavigationDrawer/res/values-v11/template-styles.xml b/samples/browseable/NavigationDrawer/res/values-v11/template-styles.xml index f261bb5c2..8c1ea66f2 100644 --- a/samples/browseable/NavigationDrawer/res/values-v11/template-styles.xml +++ b/samples/browseable/NavigationDrawer/res/values-v11/template-styles.xml @@ -17,6 +17,6 @@ - diff --git a/samples/browseable/NavigationDrawer/src/com.example.android.navigationdrawer/NavigationDrawerActivity.java b/samples/browseable/NavigationDrawer/src/com.example.android.navigationdrawer/NavigationDrawerActivity.java index 117675701..26d677b79 100644 --- a/samples/browseable/NavigationDrawer/src/com.example.android.navigationdrawer/NavigationDrawerActivity.java +++ b/samples/browseable/NavigationDrawer/src/com.example.android.navigationdrawer/NavigationDrawerActivity.java @@ -27,7 +27,6 @@ import android.os.Bundle; import android.support.v4.app.ActionBarDrawerToggle; import android.support.v4.view.GravityCompat; import android.support.v4.widget.DrawerLayout; -import android.support.v7.widget.LinearLayoutManager; import android.support.v7.widget.RecyclerView; import android.view.LayoutInflater; import android.view.Menu; @@ -89,7 +88,6 @@ public class NavigationDrawerActivity extends Activity implements PlanetAdapter. mDrawerLayout.setDrawerShadow(R.drawable.drawer_shadow, GravityCompat.START); // improve performance by indicating the list if fixed size. mDrawerList.setHasFixedSize(true); - mDrawerList.setLayoutManager(new LinearLayoutManager(this)); // set up the drawer's list view with items and click listener mDrawerList.setAdapter(new PlanetAdapter(mPlanetTitles, this)); diff --git a/samples/browseable/NetworkConnect/res/values-v11/template-styles.xml b/samples/browseable/NetworkConnect/res/values-v11/template-styles.xml index f261bb5c2..8c1ea66f2 100644 --- a/samples/browseable/NetworkConnect/res/values-v11/template-styles.xml +++ b/samples/browseable/NetworkConnect/res/values-v11/template-styles.xml @@ -17,6 +17,6 @@ - diff --git a/samples/browseable/NetworkConnect/res/values/template-styles.xml b/samples/browseable/NetworkConnect/res/values/template-styles.xml index 74086d293..6e7d593dd 100644 --- a/samples/browseable/NetworkConnect/res/values/template-styles.xml +++ b/samples/browseable/NetworkConnect/res/values/template-styles.xml @@ -18,7 +18,7 @@ - diff --git a/samples/browseable/NfcProvisioning/res/values/template-styles.xml b/samples/browseable/NfcProvisioning/res/values/template-styles.xml index 74086d293..6e7d593dd 100644 --- a/samples/browseable/NfcProvisioning/res/values/template-styles.xml +++ b/samples/browseable/NfcProvisioning/res/values/template-styles.xml @@ -18,7 +18,7 @@ - diff --git a/samples/browseable/Notifications/Application/res/values/template-styles.xml b/samples/browseable/Notifications/Application/res/values/template-styles.xml index 74086d293..6e7d593dd 100644 --- a/samples/browseable/Notifications/Application/res/values/template-styles.xml +++ b/samples/browseable/Notifications/Application/res/values/template-styles.xml @@ -18,7 +18,7 @@ - diff --git a/samples/browseable/PdfRendererBasic/res/values/template-styles.xml b/samples/browseable/PdfRendererBasic/res/values/template-styles.xml index 74086d293..6e7d593dd 100644 --- a/samples/browseable/PdfRendererBasic/res/values/template-styles.xml +++ b/samples/browseable/PdfRendererBasic/res/values/template-styles.xml @@ -18,7 +18,7 @@ - diff --git a/samples/browseable/PermissionRequest/res/values/template-styles.xml b/samples/browseable/PermissionRequest/res/values/template-styles.xml index 74086d293..6e7d593dd 100644 --- a/samples/browseable/PermissionRequest/res/values/template-styles.xml +++ b/samples/browseable/PermissionRequest/res/values/template-styles.xml @@ -18,7 +18,7 @@ - diff --git a/samples/browseable/Quiz/Application/res/values/template-styles.xml b/samples/browseable/Quiz/Application/res/values/template-styles.xml index 74086d293..6e7d593dd 100644 --- a/samples/browseable/Quiz/Application/res/values/template-styles.xml +++ b/samples/browseable/Quiz/Application/res/values/template-styles.xml @@ -18,7 +18,7 @@ - diff --git a/samples/browseable/RecipeAssistant/Application/res/values/template-styles.xml b/samples/browseable/RecipeAssistant/Application/res/values/template-styles.xml index 74086d293..6e7d593dd 100644 --- a/samples/browseable/RecipeAssistant/Application/res/values/template-styles.xml +++ b/samples/browseable/RecipeAssistant/Application/res/values/template-styles.xml @@ -18,7 +18,7 @@ - diff --git a/samples/browseable/RecyclerView/res/values/template-styles.xml b/samples/browseable/RecyclerView/res/values/template-styles.xml index 74086d293..6e7d593dd 100644 --- a/samples/browseable/RecyclerView/res/values/template-styles.xml +++ b/samples/browseable/RecyclerView/res/values/template-styles.xml @@ -18,7 +18,7 @@ - diff --git a/samples/browseable/RenderScriptIntrinsic/res/values/template-styles.xml b/samples/browseable/RenderScriptIntrinsic/res/values/template-styles.xml index 74086d293..6e7d593dd 100644 --- a/samples/browseable/RenderScriptIntrinsic/res/values/template-styles.xml +++ b/samples/browseable/RenderScriptIntrinsic/res/values/template-styles.xml @@ -18,7 +18,7 @@ - diff --git a/samples/browseable/RevealEffectBasic/res/values-v11/template-styles.xml b/samples/browseable/RevealEffectBasic/res/values-v11/template-styles.xml index f261bb5c2..8c1ea66f2 100644 --- a/samples/browseable/RevealEffectBasic/res/values-v11/template-styles.xml +++ b/samples/browseable/RevealEffectBasic/res/values-v11/template-styles.xml @@ -17,6 +17,6 @@ - diff --git a/samples/browseable/RevealEffectBasic/res/values/template-styles.xml b/samples/browseable/RevealEffectBasic/res/values/template-styles.xml index 74086d293..6e7d593dd 100644 --- a/samples/browseable/RevealEffectBasic/res/values/template-styles.xml +++ b/samples/browseable/RevealEffectBasic/res/values/template-styles.xml @@ -18,7 +18,7 @@ - diff --git a/samples/browseable/RuntimePermissions/res/values/template-styles.xml b/samples/browseable/RuntimePermissions/res/values/template-styles.xml index 74086d293..6e7d593dd 100644 --- a/samples/browseable/RuntimePermissions/res/values/template-styles.xml +++ b/samples/browseable/RuntimePermissions/res/values/template-styles.xml @@ -18,7 +18,7 @@ - diff --git a/samples/browseable/RuntimePermissionsBasic/res/values/template-styles.xml b/samples/browseable/RuntimePermissionsBasic/res/values/template-styles.xml index 74086d293..6e7d593dd 100644 --- a/samples/browseable/RuntimePermissionsBasic/res/values/template-styles.xml +++ b/samples/browseable/RuntimePermissionsBasic/res/values/template-styles.xml @@ -18,7 +18,7 @@ - + + diff --git a/samples/browseable/RuntimePermissionsWear/Application/res/values-v11/template-styles.xml b/samples/browseable/RuntimePermissionsWear/Application/res/values-v11/template-styles.xml new file mode 100644 index 000000000..8c1ea66f2 --- /dev/null +++ b/samples/browseable/RuntimePermissionsWear/Application/res/values-v11/template-styles.xml @@ -0,0 +1,22 @@ + + + + + + + + diff --git a/samples/browseable/RuntimePermissionsWear/Application/res/values-w820dp/dimens.xml b/samples/browseable/RuntimePermissionsWear/Application/res/values-w820dp/dimens.xml new file mode 100644 index 000000000..74184fc8a --- /dev/null +++ b/samples/browseable/RuntimePermissionsWear/Application/res/values-w820dp/dimens.xml @@ -0,0 +1,20 @@ + + + + + + 64dp + diff --git a/samples/browseable/RuntimePermissionsWear/Application/res/values/base-strings.xml b/samples/browseable/RuntimePermissionsWear/Application/res/values/base-strings.xml new file mode 100644 index 000000000..bf92a0b2f --- /dev/null +++ b/samples/browseable/RuntimePermissionsWear/Application/res/values/base-strings.xml @@ -0,0 +1,30 @@ + + + + + RuntimePermissionsWear + + + + diff --git a/samples/browseable/RuntimePermissionsWear/Application/res/values/dimens.xml b/samples/browseable/RuntimePermissionsWear/Application/res/values/dimens.xml new file mode 100644 index 000000000..e9366a9a3 --- /dev/null +++ b/samples/browseable/RuntimePermissionsWear/Application/res/values/dimens.xml @@ -0,0 +1,19 @@ + + + + + + 16dp + 16dp + diff --git a/samples/browseable/RuntimePermissionsWear/Application/res/values/strings.xml b/samples/browseable/RuntimePermissionsWear/Application/res/values/strings.xml new file mode 100644 index 000000000..95a2c8325 --- /dev/null +++ b/samples/browseable/RuntimePermissionsWear/Application/res/values/strings.xml @@ -0,0 +1,34 @@ + + + + + + Happy equals approved, sad equals denied.\n\nTo see results or request permissions, click on the buttons above. + You do not have the correct permissions. Tap sad face to bring up permission dialog again. + Wear Sensors + Phone Storage + + PhonePermissionRequestActivity + See your directory structure by letting us read your phone\'s storage. + Your phone and watch experience need access to your phone\'s storage to show your top level directories. + No Thanks + Continue + + WearPermissionRequestActivity + See your total sensor count by letting us read your wear\'s sensors. + Your phone and watch experience need access to your wear\'s sensors to show sensor count. + No Thanks + Open on Watch + + diff --git a/samples/browseable/RuntimePermissionsWear/Application/res/values/template-dimens.xml b/samples/browseable/RuntimePermissionsWear/Application/res/values/template-dimens.xml new file mode 100644 index 000000000..39e710b5c --- /dev/null +++ b/samples/browseable/RuntimePermissionsWear/Application/res/values/template-dimens.xml @@ -0,0 +1,32 @@ + + + + + + + 4dp + 8dp + 16dp + 32dp + 64dp + + + + @dimen/margin_medium + @dimen/margin_medium + + diff --git a/samples/browseable/RuntimePermissionsWear/Application/res/values/template-styles.xml b/samples/browseable/RuntimePermissionsWear/Application/res/values/template-styles.xml new file mode 100644 index 000000000..6e7d593dd --- /dev/null +++ b/samples/browseable/RuntimePermissionsWear/Application/res/values/template-styles.xml @@ -0,0 +1,42 @@ + + + + + + + + + + + diff --git a/samples/browseable/RuntimePermissionsWear/Application/res/values/wear.xml b/samples/browseable/RuntimePermissionsWear/Application/res/values/wear.xml new file mode 100644 index 000000000..278797263 --- /dev/null +++ b/samples/browseable/RuntimePermissionsWear/Application/res/values/wear.xml @@ -0,0 +1,19 @@ + + + + + + phone_app_runtime_permissions + + \ No newline at end of file diff --git a/samples/browseable/RuntimePermissionsWear/Application/src/com.example.android.wearable.runtimepermissions/IncomingRequestPhoneService.java b/samples/browseable/RuntimePermissionsWear/Application/src/com.example.android.wearable.runtimepermissions/IncomingRequestPhoneService.java new file mode 100644 index 000000000..4cad2fa1c --- /dev/null +++ b/samples/browseable/RuntimePermissionsWear/Application/src/com.example.android.wearable.runtimepermissions/IncomingRequestPhoneService.java @@ -0,0 +1,176 @@ +/* + * Copyright (C) 2015 Google Inc. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.example.android.wearable.runtimepermissions; + +import android.Manifest; +import android.content.Intent; +import android.content.pm.PackageManager; +import android.os.Environment; +import android.support.v4.app.ActivityCompat; +import android.util.Log; + +import com.example.android.wearable.runtimepermissions.common.Constants; + +import com.google.android.gms.common.api.GoogleApiClient; +import com.google.android.gms.common.api.PendingResult; +import com.google.android.gms.wearable.DataMap; +import com.google.android.gms.wearable.MessageApi; +import com.google.android.gms.wearable.MessageEvent; +import com.google.android.gms.wearable.Wearable; +import com.google.android.gms.wearable.WearableListenerService; + +import java.io.File; +import java.util.concurrent.TimeUnit; + +/** + * Handles all incoming requests for phone data (and permissions) from wear devices. + */ +public class IncomingRequestPhoneService extends WearableListenerService { + + private static final String TAG = "IncomingRequestService"; + + @Override + public void onCreate() { + super.onCreate(); + Log.d(TAG, "onCreate()"); + } + + @Override + public void onMessageReceived(MessageEvent messageEvent) { + super.onMessageReceived(messageEvent); + Log.d(TAG, "onMessageReceived(): " + messageEvent); + + String messagePath = messageEvent.getPath(); + + if (messagePath.equals(Constants.MESSAGE_PATH_PHONE)) { + + DataMap dataMap = DataMap.fromByteArray(messageEvent.getData()); + int requestType = dataMap.getInt(Constants.KEY_COMM_TYPE, 0); + + if (requestType == Constants.COMM_TYPE_REQUEST_PROMPT_PERMISSION) { + promptUserForStoragePermission(messageEvent.getSourceNodeId()); + + } else if (requestType == Constants.COMM_TYPE_REQUEST_DATA) { + respondWithStorageInformation(messageEvent.getSourceNodeId()); + } + } + } + + private void promptUserForStoragePermission(String nodeId) { + boolean storagePermissionApproved = + ActivityCompat.checkSelfPermission(this, Manifest.permission.READ_EXTERNAL_STORAGE) + == PackageManager.PERMISSION_GRANTED; + + if (storagePermissionApproved) { + DataMap dataMap = new DataMap(); + dataMap.putInt(Constants.KEY_COMM_TYPE, + Constants.COMM_TYPE_RESPONSE_USER_APPROVED_PERMISSION); + sendMessage(nodeId, dataMap); + } else { + // Launch Phone Activity to grant storage permissions. + Intent startIntent = new Intent(this, PhonePermissionRequestActivity.class); + startIntent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK); + + /* This extra is included to alert MainPhoneActivity to send back the permission + * results after the user has made their decision in PhonePermissionRequestActivity + * and it finishes. + */ + startIntent.putExtra(MainPhoneActivity.EXTRA_PROMPT_PERMISSION_FROM_WEAR, true); + startActivity(startIntent); + } + } + + private void respondWithStorageInformation(String nodeId) { + + boolean storagePermissionApproved = + ActivityCompat.checkSelfPermission(this, Manifest.permission.READ_EXTERNAL_STORAGE) + == PackageManager.PERMISSION_GRANTED; + + if (!storagePermissionApproved) { + DataMap dataMap = new DataMap(); + dataMap.putInt(Constants.KEY_COMM_TYPE, + Constants.COMM_TYPE_RESPONSE_PERMISSION_REQUIRED); + sendMessage(nodeId, dataMap); + } else { + /* To keep the sample simple, we are only displaying the top level list of directories. + * Otherwise, it will return a message that the media wasn't available. + */ + StringBuilder stringBuilder = new StringBuilder(); + + if (isExternalStorageReadable()) { + File externalStorageDirectory = Environment.getExternalStorageDirectory(); + String[] fileList = externalStorageDirectory.list(); + + if (fileList.length > 0) { + stringBuilder.append("List of directories on phone:\n"); + for (String file : fileList) { + stringBuilder.append(" - " + file + "\n"); + } + } else { + stringBuilder.append("No files in external storage."); + } + } else { + stringBuilder.append("No external media is available."); + } + + // Send valid results + DataMap dataMap = new DataMap(); + dataMap.putInt(Constants.KEY_COMM_TYPE, + Constants.COMM_TYPE_RESPONSE_DATA); + dataMap.putString(Constants.KEY_PAYLOAD, stringBuilder.toString()); + sendMessage(nodeId, dataMap); + + } + } + + private void sendMessage(String nodeId, DataMap dataMap) { + Log.d(TAG, "sendMessage() Node: " + nodeId); + + GoogleApiClient client = new GoogleApiClient.Builder(this) + .addApi(Wearable.API) + .build(); + client.blockingConnect(Constants.CONNECTION_TIME_OUT_MS, TimeUnit.MILLISECONDS); + + + PendingResult pendingMessageResult = + Wearable.MessageApi.sendMessage( + client, + nodeId, + Constants.MESSAGE_PATH_WEAR, + dataMap.toByteArray()); + + MessageApi.SendMessageResult sendMessageResult = + pendingMessageResult.await( + Constants.CONNECTION_TIME_OUT_MS, + TimeUnit.MILLISECONDS); + + if (!sendMessageResult.getStatus().isSuccess()) { + Log.d(TAG, "Sending message failed, status: " + + sendMessageResult.getStatus()); + } else { + Log.d(TAG, "Message sent successfully"); + } + client.disconnect(); + } + + private boolean isExternalStorageReadable() { + String state = Environment.getExternalStorageState(); + + return Environment.MEDIA_MOUNTED.equals(state) + || Environment.MEDIA_MOUNTED_READ_ONLY.equals(state); + } +} \ No newline at end of file diff --git a/samples/browseable/RuntimePermissionsWear/Application/src/com.example.android.wearable.runtimepermissions/MainPhoneActivity.java b/samples/browseable/RuntimePermissionsWear/Application/src/com.example.android.wearable.runtimepermissions/MainPhoneActivity.java new file mode 100644 index 000000000..196b03bd4 --- /dev/null +++ b/samples/browseable/RuntimePermissionsWear/Application/src/com.example.android.wearable.runtimepermissions/MainPhoneActivity.java @@ -0,0 +1,440 @@ +/* + * Copyright (C) 2015 Google Inc. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.example.android.wearable.runtimepermissions; + +import android.Manifest; +import android.app.Activity; +import android.content.Intent; +import android.content.pm.PackageManager; +import android.os.Environment; +import android.os.Looper; +import android.support.v4.app.ActivityCompat; +import android.support.v7.app.AppCompatActivity; +import android.os.Bundle; +import android.util.Log; +import android.view.View; +import android.widget.Button; +import android.widget.TextView; + +import com.example.android.wearable.runtimepermissions.common.Constants; + +import com.google.android.gms.common.ConnectionResult; +import com.google.android.gms.common.api.GoogleApiClient; +import com.google.android.gms.common.api.PendingResult; +import com.google.android.gms.common.api.ResultCallback; +import com.google.android.gms.wearable.CapabilityApi; +import com.google.android.gms.wearable.CapabilityInfo; +import com.google.android.gms.wearable.DataMap; +import com.google.android.gms.wearable.MessageApi; +import com.google.android.gms.wearable.MessageEvent; +import com.google.android.gms.wearable.Node; +import com.google.android.gms.wearable.Wearable; + +import java.io.File; +import java.util.Set; +import java.util.concurrent.TimeUnit; + +/** + * Displays data that requires runtime permissions both locally (READ_EXTERNAL_STORAGE) and + * remotely on wear (BODY_SENSORS). + * + * The class also handles sending back the results of a permission request from a remote wear device + * when the permission has not been approved yet on the phone (uses EXTRA as trigger). In that case, + * the IncomingRequestPhoneService launches the splash Activity (PhonePermissionRequestActivity) to + * inform user of permission request. After the user decides what to do, it falls back to this + * Activity (which has all the GoogleApiClient code) to handle sending data across and keeps user + * in app experience. + */ +public class MainPhoneActivity extends AppCompatActivity implements + GoogleApiClient.ConnectionCallbacks, + GoogleApiClient.OnConnectionFailedListener, + CapabilityApi.CapabilityListener, + MessageApi.MessageListener, + ResultCallback { + + private static final String TAG = "MainPhoneActivity"; + + /* + * Alerts Activity that the initial request for permissions came from wear, and the Activity + * needs to send back the results (data or permission rejection). + */ + public static final String EXTRA_PROMPT_PERMISSION_FROM_WEAR = + "com.example.android.wearable.runtimepermissions.extra.PROMPT_PERMISSION_FROM_WEAR"; + + private static final int REQUEST_WEAR_PERMISSION_RATIONALE = 1; + + private boolean mWearBodySensorsPermissionApproved; + private boolean mPhoneStoragePermissionApproved; + + private boolean mWearRequestingPhoneStoragePermission; + + private Button mWearBodySensorsPermissionButton; + private Button mPhoneStoragePermissionButton; + private TextView mOutputTextView; + + private Set mWearNodeIds; + + private GoogleApiClient mGoogleApiClient; + + @Override + protected void onCreate(Bundle savedInstanceState) { + Log.d(TAG, "onCreate()"); + super.onCreate(savedInstanceState); + + /* + * Since this is a remote permission, we initialize it to false and then check the remote + * permission once the GoogleApiClient is connected. + */ + mWearBodySensorsPermissionApproved = false; + + setContentView(R.layout.activity_main); + + // Checks if wear app requested phone permission (permission request opens later if true). + mWearRequestingPhoneStoragePermission = + getIntent().getBooleanExtra(EXTRA_PROMPT_PERMISSION_FROM_WEAR, false); + + mPhoneStoragePermissionButton = + (Button) findViewById(R.id.phoneStoragePermissionButton); + + mWearBodySensorsPermissionButton = + (Button) findViewById(R.id.wearBodySensorsPermissionButton); + + mOutputTextView = (TextView) findViewById(R.id.output); + + mGoogleApiClient = new GoogleApiClient.Builder(this) + .addApi(Wearable.API) + .addConnectionCallbacks(this) + .addOnConnectionFailedListener(this) + .build(); + } + + public void onClickWearBodySensors(View view) { + + logToUi("Requested info from wear device(s). New approval may be required."); + + DataMap dataMap = new DataMap(); + dataMap.putInt(Constants.KEY_COMM_TYPE, Constants.COMM_TYPE_REQUEST_DATA); + sendMessage(dataMap); + } + + public void onClickPhoneStorage(View view) { + + if (mPhoneStoragePermissionApproved) { + logToUi(getPhoneStorageInformation()); + + } else { + // On 23+ (M+) devices, Storage permission not granted. Request permission. + Intent startIntent = new Intent(this, PhonePermissionRequestActivity.class); + startActivity(startIntent); + } + } + + @Override + protected void onPause() { + Log.d(TAG, "onPause()"); + super.onPause(); + if ((mGoogleApiClient != null) && (mGoogleApiClient.isConnected())) { + Wearable.CapabilityApi.removeCapabilityListener( + mGoogleApiClient, + this, + Constants.CAPABILITY_WEAR_APP); + Wearable.MessageApi.removeListener(mGoogleApiClient, this); + mGoogleApiClient.disconnect(); + } + } + + @Override + protected void onResume() { + Log.d(TAG, "onResume()"); + super.onResume(); + + /* Enables app to handle 23+ (M+) style permissions. It also covers user changing + * permission in settings and coming back to the app. + */ + mPhoneStoragePermissionApproved = + ActivityCompat.checkSelfPermission(this, Manifest.permission.READ_EXTERNAL_STORAGE) + == PackageManager.PERMISSION_GRANTED; + + if (mPhoneStoragePermissionApproved) { + mPhoneStoragePermissionButton.setCompoundDrawablesWithIntrinsicBounds( + R.drawable.ic_permission_approved, 0, 0, 0); + } + + if (mGoogleApiClient != null) { + mGoogleApiClient.connect(); + } + } + + @Override + protected void onActivityResult(int requestCode, int resultCode, Intent data) { + Log.d(TAG, "onActivityResult()"); + if (requestCode == REQUEST_WEAR_PERMISSION_RATIONALE) { + + if (resultCode == Activity.RESULT_OK) { + logToUi("Requested permission on wear device(s)."); + + DataMap dataMap = new DataMap(); + dataMap.putInt(Constants.KEY_COMM_TYPE, + Constants.COMM_TYPE_REQUEST_PROMPT_PERMISSION); + sendMessage(dataMap); + } + } + } + + @Override + public void onConnected(Bundle bundle) { + Log.d(TAG, "onConnected()"); + + // Set up listeners for capability and message changes. + Wearable.CapabilityApi.addCapabilityListener( + mGoogleApiClient, + this, + Constants.CAPABILITY_WEAR_APP); + Wearable.MessageApi.addListener(mGoogleApiClient, this); + + // Initial check of capabilities to find the wear nodes. + PendingResult pendingResult = + Wearable.CapabilityApi.getCapability( + mGoogleApiClient, + Constants.CAPABILITY_WEAR_APP, + CapabilityApi.FILTER_REACHABLE); + + pendingResult.setResultCallback(new ResultCallback() { + @Override + public void onResult(CapabilityApi.GetCapabilityResult getCapabilityResult) { + + CapabilityInfo capabilityInfo = getCapabilityResult.getCapability(); + String capabilityName = capabilityInfo.getName(); + + boolean wearSupportsSampleApp = + capabilityName.equals(Constants.CAPABILITY_WEAR_APP); + + if (wearSupportsSampleApp) { + mWearNodeIds = capabilityInfo.getNodes(); + + /* + * Upon getting all wear nodes, we now need to check if the original request to + * launch this activity (and PhonePermissionRequestActivity) was initiated by + * a wear device. If it was, we need to send back the permission results (data + * or rejection of permission) to the wear device. + * + * Also, note we set variable to false, this enables the user to continue + * changing permissions without sending updates to the wear every time. + */ + if (mWearRequestingPhoneStoragePermission) { + mWearRequestingPhoneStoragePermission = false; + sendWearPermissionResults(); + } + } + } + }); + } + + @Override + public void onConnectionSuspended(int i) { + Log.d(TAG, "onConnectionSuspended(): connection to location client suspended"); + } + + @Override + public void onConnectionFailed(ConnectionResult connectionResult) { + Log.e(TAG, "onConnectionFailed(): connection to location client failed"); + } + + + public void onCapabilityChanged(CapabilityInfo capabilityInfo) { + Log.d(TAG, "onCapabilityChanged(): " + capabilityInfo); + + mWearNodeIds = capabilityInfo.getNodes(); + } + + public void onMessageReceived(MessageEvent messageEvent) { + Log.d(TAG, "onMessageReceived(): " + messageEvent); + + String messagePath = messageEvent.getPath(); + + if (messagePath.equals(Constants.MESSAGE_PATH_PHONE)) { + DataMap dataMap = DataMap.fromByteArray(messageEvent.getData()); + + int commType = dataMap.getInt(Constants.KEY_COMM_TYPE, 0); + + if (commType == Constants.COMM_TYPE_RESPONSE_PERMISSION_REQUIRED) { + mWearBodySensorsPermissionApproved = false; + updateWearButtonOnUiThread(); + + /* Because our request for remote data requires a remote permission, we now launch + * a splash activity informing the user we need those permissions (along with + * other helpful information to approve). + */ + Intent wearPermissionRationale = + new Intent(this, WearPermissionRequestActivity.class); + startActivityForResult(wearPermissionRationale, REQUEST_WEAR_PERMISSION_RATIONALE); + + } else if (commType == Constants.COMM_TYPE_RESPONSE_USER_APPROVED_PERMISSION) { + mWearBodySensorsPermissionApproved = true; + updateWearButtonOnUiThread(); + logToUi("User approved permission on remote device, requesting data again."); + DataMap outgoingDataRequestDataMap = new DataMap(); + outgoingDataRequestDataMap.putInt(Constants.KEY_COMM_TYPE, + Constants.COMM_TYPE_REQUEST_DATA); + sendMessage(outgoingDataRequestDataMap); + + } else if (commType == Constants.COMM_TYPE_RESPONSE_USER_DENIED_PERMISSION) { + mWearBodySensorsPermissionApproved = false; + updateWearButtonOnUiThread(); + logToUi("User denied permission on remote device."); + + } else if (commType == Constants.COMM_TYPE_RESPONSE_DATA) { + mWearBodySensorsPermissionApproved = true; + String storageDetails = dataMap.getString(Constants.KEY_PAYLOAD); + updateWearButtonOnUiThread(); + logToUi(storageDetails); + + } else { + Log.d(TAG, "Unrecognized communication type received."); + } + } + } + + @Override + public void onResult(MessageApi.SendMessageResult sendMessageResult) { + if (!sendMessageResult.getStatus().isSuccess()) { + Log.d(TAG, "Sending message failed, onResult: " + sendMessageResult); + updateWearButtonOnUiThread(); + logToUi("Sending message failed."); + + } else { + Log.d(TAG, "Message sent."); + } + } + + private void sendMessage(DataMap dataMap) { + Log.d(TAG, "sendMessage(): " + mWearNodeIds); + + if ((mWearNodeIds != null) && (!mWearNodeIds.isEmpty())) { + + PendingResult pendingResult; + + for (Node node : mWearNodeIds) { + + pendingResult = Wearable.MessageApi.sendMessage( + mGoogleApiClient, + node.getId(), + Constants.MESSAGE_PATH_WEAR, + dataMap.toByteArray()); + + pendingResult.setResultCallback(this, Constants.CONNECTION_TIME_OUT_MS, + TimeUnit.SECONDS); + } + } else { + // Unable to retrieve node with proper capability + mWearBodySensorsPermissionApproved = false; + updateWearButtonOnUiThread(); + logToUi("Wear devices not available to send message."); + } + } + + private void updateWearButtonOnUiThread() { + runOnUiThread(new Runnable() { + @Override + public void run() { + if (mWearBodySensorsPermissionApproved) { + mWearBodySensorsPermissionButton.setCompoundDrawablesWithIntrinsicBounds( + R.drawable.ic_permission_approved, 0, 0, 0); + } else { + mWearBodySensorsPermissionButton.setCompoundDrawablesWithIntrinsicBounds( + R.drawable.ic_permission_denied, 0, 0, 0); + } + } + }); + } + + /* + * Handles all messages for the UI coming on and off the main thread. Not all callbacks happen + * on the main thread. + */ + private void logToUi(final String message) { + + boolean mainUiThread = (Looper.myLooper() == Looper.getMainLooper()); + + if (mainUiThread) { + + if (!message.isEmpty()) { + Log.d(TAG, message); + mOutputTextView.setText(message); + } + + } else { + if (!message.isEmpty()) { + + runOnUiThread(new Runnable() { + @Override + public void run() { + + Log.d(TAG, message); + mOutputTextView.setText(message); + } + }); + } + } + } + + private String getPhoneStorageInformation() { + + StringBuilder stringBuilder = new StringBuilder(); + + String state = Environment.getExternalStorageState(); + boolean isExternalStorageReadable = Environment.MEDIA_MOUNTED.equals(state) + || Environment.MEDIA_MOUNTED_READ_ONLY.equals(state); + + if (isExternalStorageReadable) { + File externalStorageDirectory = Environment.getExternalStorageDirectory(); + String[] fileList = externalStorageDirectory.list(); + + if (fileList.length > 0) { + + stringBuilder.append("List of files\n"); + for (String file : fileList) { + stringBuilder.append(" - " + file + "\n"); + } + + } else { + stringBuilder.append("No files in external storage."); + } + + } else { + stringBuilder.append("No external media is available."); + } + + return stringBuilder.toString(); + } + + private void sendWearPermissionResults() { + + Log.d(TAG, "sendWearPermissionResults()"); + + DataMap dataMap = new DataMap(); + + if (mPhoneStoragePermissionApproved) { + dataMap.putInt(Constants.KEY_COMM_TYPE, + Constants.COMM_TYPE_RESPONSE_USER_APPROVED_PERMISSION); + } else { + dataMap.putInt(Constants.KEY_COMM_TYPE, + Constants.COMM_TYPE_RESPONSE_USER_DENIED_PERMISSION); + } + sendMessage(dataMap); + } +} diff --git a/samples/browseable/RuntimePermissionsWear/Application/src/com.example.android.wearable.runtimepermissions/PhonePermissionRequestActivity.java b/samples/browseable/RuntimePermissionsWear/Application/src/com.example.android.wearable.runtimepermissions/PhonePermissionRequestActivity.java new file mode 100644 index 000000000..0b10e35d1 --- /dev/null +++ b/samples/browseable/RuntimePermissionsWear/Application/src/com.example.android.wearable.runtimepermissions/PhonePermissionRequestActivity.java @@ -0,0 +1,114 @@ +/* + * Copyright (C) 2015 Google Inc. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.example.android.wearable.runtimepermissions; + +import android.Manifest; +import android.content.Intent; +import android.content.pm.PackageManager; +import android.os.Bundle; +import android.support.annotation.NonNull; +import android.support.v4.app.ActivityCompat; +import android.support.v7.app.AppCompatActivity; +import android.util.Log; +import android.view.View; + +/** + * This is a simple splash screen (activity) for giving more details on why the user should approve + * phone permissions for storage. If they choose to move forward, the permission screen + * is brought up. Either way (approve or disapprove), this will exit to the MainPhoneActivity after + * they are finished with their final decision. + * + * If this activity is started by our service (IncomingRequestPhoneService) it is marked via an + * extra (MainPhoneActivity.EXTRA_PROMPT_PERMISSION_FROM_WEAR). That service only starts + * this activity if the phone permission hasn't been approved for the data wear is trying to access. + * When the user decides within this Activity what to do with the permission request, it closes and + * opens the MainPhoneActivity (to maintain the app experience). It also again passes along the same + * extra (MainPhoneActivity.EXTRA_PROMPT_PERMISSION_FROM_WEAR) to alert MainPhoneActivity to + * send the results of the user's decision to the wear device. + */ +public class PhonePermissionRequestActivity extends AppCompatActivity implements + ActivityCompat.OnRequestPermissionsResultCallback { + + private static final String TAG = "PhoneRationale"; + + /* Id to identify Location permission request. */ + private static final int PERMISSION_REQUEST_READ_STORAGE = 1; + + @Override + protected void onCreate(Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + + // If permissions granted, we start the main activity (shut this activity down). + if (ActivityCompat.checkSelfPermission(this, Manifest.permission.READ_EXTERNAL_STORAGE) + == PackageManager.PERMISSION_GRANTED) { + startMainActivity(); + } + + setContentView(R.layout.activity_phone_permission_request); + } + + public void onClickApprovePermissionRequest(View view) { + Log.d(TAG, "onClickApprovePermissionRequest()"); + + // On 23+ (M+) devices, External storage permission not granted. Request permission. + ActivityCompat.requestPermissions( + this, + new String[]{Manifest.permission.READ_EXTERNAL_STORAGE}, + PERMISSION_REQUEST_READ_STORAGE); + } + + public void onClickDenyPermissionRequest(View view) { + Log.d(TAG, "onClickDenyPermissionRequest()"); + startMainActivity(); + } + + /* + * Callback received when a permissions request has been completed. + */ + @Override + public void onRequestPermissionsResult( + int requestCode, @NonNull String[] permissions, @NonNull int[] grantResults) { + + String permissionResult = "Request code: " + requestCode + ", Permissions: " + permissions + + ", Results: " + grantResults; + Log.d(TAG, "onRequestPermissionsResult(): " + permissionResult); + + if (requestCode == PERMISSION_REQUEST_READ_STORAGE) { + // Close activity regardless of user's decision (decision picked up in main activity). + startMainActivity(); + } + } + + private void startMainActivity() { + + Intent mainActivityIntent = new Intent(this, MainPhoneActivity.class); + + /* + * If service started this Activity (b/c wear requested data where permissions were not + * approved), tells MainPhoneActivity to send results to wear device (via this extra). + */ + boolean serviceStartedActivity = getIntent().getBooleanExtra( + MainPhoneActivity.EXTRA_PROMPT_PERMISSION_FROM_WEAR, false); + + if (serviceStartedActivity) { + mainActivityIntent.putExtra( + MainPhoneActivity.EXTRA_PROMPT_PERMISSION_FROM_WEAR, true); + } + + startActivity(mainActivityIntent); + } +} \ No newline at end of file diff --git a/samples/browseable/RuntimePermissionsWear/Application/src/com.example.android.wearable.runtimepermissions/WearPermissionRequestActivity.java b/samples/browseable/RuntimePermissionsWear/Application/src/com.example.android.wearable.runtimepermissions/WearPermissionRequestActivity.java new file mode 100644 index 000000000..3340ef6bd --- /dev/null +++ b/samples/browseable/RuntimePermissionsWear/Application/src/com.example.android.wearable.runtimepermissions/WearPermissionRequestActivity.java @@ -0,0 +1,62 @@ +/* + * Copyright (C) 2015 Google Inc. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.example.android.wearable.runtimepermissions; + +import android.app.Activity; +import android.os.Bundle; +import android.support.v4.app.ActivityCompat; +import android.support.v7.app.AppCompatActivity; +import android.util.Log; +import android.view.View; + +/** + * This is a simple splash screen (activity) for giving more details on why the user should approve + * phone permissions for storage. If they choose to move forward, the permission screen + * is brought up. Either way (approve or disapprove), this will exit to the MainPhoneActivity after + * they are finished with their final decision. + * + * If this activity is started by our service (IncomingRequestPhoneService) it is marked via an + * extra (MainPhoneActivity.EXTRA_PROMPT_PERMISSION_FROM_WEAR). That service only starts + * this activity if the phone permission hasn't been approved for the data wear is trying to access. + * When the user decides within this Activity what to do with the permission request, it closes and + * opens the MainPhoneActivity (to maintain the app experience). It also again passes along the same + * extra (MainPhoneActivity.EXTRA_PROMPT_PERMISSION_FROM_WEAR) to alert MainPhoneActivity to + * send the results of the user's decision to the wear device. + */ +public class WearPermissionRequestActivity extends AppCompatActivity implements + ActivityCompat.OnRequestPermissionsResultCallback { + + private static final String TAG = "WearRationale"; + + @Override + protected void onCreate(Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + setContentView(R.layout.activity_wear_permission_request); + } + + public void onClickApprovePermissionRequest(View view) { + Log.d(TAG, "onClickApprovePermissionRequest()"); + setResult(Activity.RESULT_OK); + finish(); + } + + public void onClickDenyPermissionRequest(View view) { + Log.d(TAG, "onClickDenyPermissionRequest()"); + setResult(Activity.RESULT_CANCELED); + finish(); + } +} \ No newline at end of file diff --git a/samples/browseable/RuntimePermissionsWear/Shared/AndroidManifest.xml b/samples/browseable/RuntimePermissionsWear/Shared/AndroidManifest.xml new file mode 100644 index 000000000..fa262fa5e --- /dev/null +++ b/samples/browseable/RuntimePermissionsWear/Shared/AndroidManifest.xml @@ -0,0 +1,22 @@ + + + + + + + + + + diff --git a/samples/browseable/RuntimePermissionsWear/Shared/res/values/strings.xml b/samples/browseable/RuntimePermissionsWear/Shared/res/values/strings.xml new file mode 100644 index 000000000..cc0aaa9af --- /dev/null +++ b/samples/browseable/RuntimePermissionsWear/Shared/res/values/strings.xml @@ -0,0 +1,17 @@ + + + + + Shared + diff --git a/samples/browseable/RuntimePermissionsWear/Shared/src/com.example.android.wearable.runtimepermissions.common/Constants.java b/samples/browseable/RuntimePermissionsWear/Shared/src/com.example.android.wearable.runtimepermissions.common/Constants.java new file mode 100644 index 000000000..d124400a8 --- /dev/null +++ b/samples/browseable/RuntimePermissionsWear/Shared/src/com.example.android.wearable.runtimepermissions.common/Constants.java @@ -0,0 +1,51 @@ +/* + * Copyright (C) 2015 Google Inc. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.example.android.wearable.runtimepermissions.common; + +import java.util.concurrent.TimeUnit; + +/** + * A collection of constants that is shared between the wearable and handset apps. + */ +public class Constants { + + // Shared + public static final long CONNECTION_TIME_OUT_MS = TimeUnit.SECONDS.toMillis(5); + + public static final String KEY_COMM_TYPE = "communicationType"; + public static final String KEY_PAYLOAD = "payload"; + + // Requests + public static final int COMM_TYPE_REQUEST_PROMPT_PERMISSION = 1; + public static final int COMM_TYPE_REQUEST_DATA = 2; + + // Responses + public static final int COMM_TYPE_RESPONSE_PERMISSION_REQUIRED = 1001; + public static final int COMM_TYPE_RESPONSE_USER_APPROVED_PERMISSION = 1002; + public static final int COMM_TYPE_RESPONSE_USER_DENIED_PERMISSION = 1003; + public static final int COMM_TYPE_RESPONSE_DATA = 1004; + + // Phone + public static final String CAPABILITY_PHONE_APP = "phone_app_runtime_permissions"; + public static final String MESSAGE_PATH_PHONE = "/phone_message_path"; + + // Wear + public static final String CAPABILITY_WEAR_APP = "wear_app_runtime_permissions"; + public static final String MESSAGE_PATH_WEAR = "/wear_message_path"; + + private Constants() {} +} \ No newline at end of file diff --git a/samples/browseable/RuntimePermissionsWear/Wearable/AndroidManifest.xml b/samples/browseable/RuntimePermissionsWear/Wearable/AndroidManifest.xml new file mode 100644 index 000000000..43218d789 --- /dev/null +++ b/samples/browseable/RuntimePermissionsWear/Wearable/AndroidManifest.xml @@ -0,0 +1,68 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/samples/browseable/RuntimePermissionsWear/Wearable/res/drawable-hdpi/ic_cc_open_on_phone.png b/samples/browseable/RuntimePermissionsWear/Wearable/res/drawable-hdpi/ic_cc_open_on_phone.png new file mode 100644 index 000000000..618a44f67 Binary files /dev/null and b/samples/browseable/RuntimePermissionsWear/Wearable/res/drawable-hdpi/ic_cc_open_on_phone.png differ diff --git a/samples/browseable/RuntimePermissionsWear/Wearable/res/drawable-hdpi/ic_permission_approved.png b/samples/browseable/RuntimePermissionsWear/Wearable/res/drawable-hdpi/ic_permission_approved.png new file mode 100644 index 000000000..79893302c Binary files /dev/null and b/samples/browseable/RuntimePermissionsWear/Wearable/res/drawable-hdpi/ic_permission_approved.png differ diff --git a/samples/browseable/RuntimePermissionsWear/Wearable/res/drawable-hdpi/ic_permission_approved_bw.png b/samples/browseable/RuntimePermissionsWear/Wearable/res/drawable-hdpi/ic_permission_approved_bw.png new file mode 100644 index 000000000..bbd7e8a96 Binary files /dev/null and b/samples/browseable/RuntimePermissionsWear/Wearable/res/drawable-hdpi/ic_permission_approved_bw.png differ diff --git a/samples/browseable/RuntimePermissionsWear/Wearable/res/drawable-hdpi/ic_permission_denied.png b/samples/browseable/RuntimePermissionsWear/Wearable/res/drawable-hdpi/ic_permission_denied.png new file mode 100644 index 000000000..814bb6359 Binary files /dev/null and b/samples/browseable/RuntimePermissionsWear/Wearable/res/drawable-hdpi/ic_permission_denied.png differ diff --git a/samples/browseable/RuntimePermissionsWear/Wearable/res/drawable-hdpi/ic_permission_denied_bw.png b/samples/browseable/RuntimePermissionsWear/Wearable/res/drawable-hdpi/ic_permission_denied_bw.png new file mode 100644 index 000000000..accd6c002 Binary files /dev/null and b/samples/browseable/RuntimePermissionsWear/Wearable/res/drawable-hdpi/ic_permission_denied_bw.png differ diff --git a/samples/browseable/RuntimePermissionsWear/Wearable/res/drawable-mdpi/ic_cc_open_on_phone.png b/samples/browseable/RuntimePermissionsWear/Wearable/res/drawable-mdpi/ic_cc_open_on_phone.png new file mode 100644 index 000000000..e66ba6b25 Binary files /dev/null and b/samples/browseable/RuntimePermissionsWear/Wearable/res/drawable-mdpi/ic_cc_open_on_phone.png differ diff --git a/samples/browseable/RuntimePermissionsWear/Wearable/res/drawable-mdpi/ic_permission_approved.png b/samples/browseable/RuntimePermissionsWear/Wearable/res/drawable-mdpi/ic_permission_approved.png new file mode 100644 index 000000000..1e63d37c0 Binary files /dev/null and b/samples/browseable/RuntimePermissionsWear/Wearable/res/drawable-mdpi/ic_permission_approved.png differ diff --git a/samples/browseable/RuntimePermissionsWear/Wearable/res/drawable-mdpi/ic_permission_approved_bw.png b/samples/browseable/RuntimePermissionsWear/Wearable/res/drawable-mdpi/ic_permission_approved_bw.png new file mode 100644 index 000000000..16050cb25 Binary files /dev/null and b/samples/browseable/RuntimePermissionsWear/Wearable/res/drawable-mdpi/ic_permission_approved_bw.png differ diff --git a/samples/browseable/RuntimePermissionsWear/Wearable/res/drawable-mdpi/ic_permission_denied.png b/samples/browseable/RuntimePermissionsWear/Wearable/res/drawable-mdpi/ic_permission_denied.png new file mode 100644 index 000000000..45a0d8750 Binary files /dev/null and b/samples/browseable/RuntimePermissionsWear/Wearable/res/drawable-mdpi/ic_permission_denied.png differ diff --git a/samples/browseable/RuntimePermissionsWear/Wearable/res/drawable-mdpi/ic_permission_denied_bw.png b/samples/browseable/RuntimePermissionsWear/Wearable/res/drawable-mdpi/ic_permission_denied_bw.png new file mode 100644 index 000000000..376d47193 Binary files /dev/null and b/samples/browseable/RuntimePermissionsWear/Wearable/res/drawable-mdpi/ic_permission_denied_bw.png differ diff --git a/samples/browseable/RuntimePermissionsWear/Wearable/res/drawable-xhdpi/ic_cc_open_on_phone.png b/samples/browseable/RuntimePermissionsWear/Wearable/res/drawable-xhdpi/ic_cc_open_on_phone.png new file mode 100644 index 000000000..5522d6c3f Binary files /dev/null and b/samples/browseable/RuntimePermissionsWear/Wearable/res/drawable-xhdpi/ic_cc_open_on_phone.png differ diff --git a/samples/browseable/RuntimePermissionsWear/Wearable/res/drawable-xhdpi/ic_permission_approved.png b/samples/browseable/RuntimePermissionsWear/Wearable/res/drawable-xhdpi/ic_permission_approved.png new file mode 100644 index 000000000..24d1efbcc Binary files /dev/null and b/samples/browseable/RuntimePermissionsWear/Wearable/res/drawable-xhdpi/ic_permission_approved.png differ diff --git a/samples/browseable/RuntimePermissionsWear/Wearable/res/drawable-xhdpi/ic_permission_approved_bw.png b/samples/browseable/RuntimePermissionsWear/Wearable/res/drawable-xhdpi/ic_permission_approved_bw.png new file mode 100644 index 000000000..2682e30ae Binary files /dev/null and b/samples/browseable/RuntimePermissionsWear/Wearable/res/drawable-xhdpi/ic_permission_approved_bw.png differ diff --git a/samples/browseable/RuntimePermissionsWear/Wearable/res/drawable-xhdpi/ic_permission_denied.png b/samples/browseable/RuntimePermissionsWear/Wearable/res/drawable-xhdpi/ic_permission_denied.png new file mode 100644 index 000000000..17f093d88 Binary files /dev/null and b/samples/browseable/RuntimePermissionsWear/Wearable/res/drawable-xhdpi/ic_permission_denied.png differ diff --git a/samples/browseable/RuntimePermissionsWear/Wearable/res/drawable-xhdpi/ic_permission_denied_bw.png b/samples/browseable/RuntimePermissionsWear/Wearable/res/drawable-xhdpi/ic_permission_denied_bw.png new file mode 100644 index 000000000..cd9d000b4 Binary files /dev/null and b/samples/browseable/RuntimePermissionsWear/Wearable/res/drawable-xhdpi/ic_permission_denied_bw.png differ diff --git a/samples/browseable/RuntimePermissionsWear/Wearable/res/drawable-xxhdpi/ic_permission_approved.png b/samples/browseable/RuntimePermissionsWear/Wearable/res/drawable-xxhdpi/ic_permission_approved.png new file mode 100644 index 000000000..f29c5a341 Binary files /dev/null and b/samples/browseable/RuntimePermissionsWear/Wearable/res/drawable-xxhdpi/ic_permission_approved.png differ diff --git a/samples/browseable/RuntimePermissionsWear/Wearable/res/drawable-xxhdpi/ic_permission_approved_bw.png b/samples/browseable/RuntimePermissionsWear/Wearable/res/drawable-xxhdpi/ic_permission_approved_bw.png new file mode 100644 index 000000000..1bcf27a78 Binary files /dev/null and b/samples/browseable/RuntimePermissionsWear/Wearable/res/drawable-xxhdpi/ic_permission_approved_bw.png differ diff --git a/samples/browseable/RuntimePermissionsWear/Wearable/res/drawable-xxhdpi/ic_permission_denied.png b/samples/browseable/RuntimePermissionsWear/Wearable/res/drawable-xxhdpi/ic_permission_denied.png new file mode 100644 index 000000000..52b0671f6 Binary files /dev/null and b/samples/browseable/RuntimePermissionsWear/Wearable/res/drawable-xxhdpi/ic_permission_denied.png differ diff --git a/samples/browseable/RuntimePermissionsWear/Wearable/res/drawable-xxhdpi/ic_permission_denied_bw.png b/samples/browseable/RuntimePermissionsWear/Wearable/res/drawable-xxhdpi/ic_permission_denied_bw.png new file mode 100644 index 000000000..229203349 Binary files /dev/null and b/samples/browseable/RuntimePermissionsWear/Wearable/res/drawable-xxhdpi/ic_permission_denied_bw.png differ diff --git a/samples/browseable/RuntimePermissionsWear/Wearable/res/drawable-xxxhdpi/ic_permission_approved.png b/samples/browseable/RuntimePermissionsWear/Wearable/res/drawable-xxxhdpi/ic_permission_approved.png new file mode 100644 index 000000000..ec642b50d Binary files /dev/null and b/samples/browseable/RuntimePermissionsWear/Wearable/res/drawable-xxxhdpi/ic_permission_approved.png differ diff --git a/samples/browseable/RuntimePermissionsWear/Wearable/res/drawable-xxxhdpi/ic_permission_approved_bw.png b/samples/browseable/RuntimePermissionsWear/Wearable/res/drawable-xxxhdpi/ic_permission_approved_bw.png new file mode 100644 index 000000000..c9eab8546 Binary files /dev/null and b/samples/browseable/RuntimePermissionsWear/Wearable/res/drawable-xxxhdpi/ic_permission_approved_bw.png differ diff --git a/samples/browseable/RuntimePermissionsWear/Wearable/res/drawable-xxxhdpi/ic_permission_denied.png b/samples/browseable/RuntimePermissionsWear/Wearable/res/drawable-xxxhdpi/ic_permission_denied.png new file mode 100644 index 000000000..35d6c4fab Binary files /dev/null and b/samples/browseable/RuntimePermissionsWear/Wearable/res/drawable-xxxhdpi/ic_permission_denied.png differ diff --git a/samples/browseable/RuntimePermissionsWear/Wearable/res/drawable-xxxhdpi/ic_permission_denied_bw.png b/samples/browseable/RuntimePermissionsWear/Wearable/res/drawable-xxxhdpi/ic_permission_denied_bw.png new file mode 100644 index 000000000..81e5355a7 Binary files /dev/null and b/samples/browseable/RuntimePermissionsWear/Wearable/res/drawable-xxxhdpi/ic_permission_denied_bw.png differ diff --git a/samples/browseable/RuntimePermissionsWear/Wearable/res/layout/activity_main.xml b/samples/browseable/RuntimePermissionsWear/Wearable/res/layout/activity_main.xml new file mode 100644 index 000000000..588ff9a88 --- /dev/null +++ b/samples/browseable/RuntimePermissionsWear/Wearable/res/layout/activity_main.xml @@ -0,0 +1,26 @@ + + + + + diff --git a/samples/browseable/RuntimePermissionsWear/Wearable/res/layout/activity_request_permission_on_phone.xml b/samples/browseable/RuntimePermissionsWear/Wearable/res/layout/activity_request_permission_on_phone.xml new file mode 100644 index 000000000..c8a5d0558 --- /dev/null +++ b/samples/browseable/RuntimePermissionsWear/Wearable/res/layout/activity_request_permission_on_phone.xml @@ -0,0 +1,74 @@ + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/samples/browseable/RuntimePermissionsWear/Wearable/res/layout/rect_activity_main.xml b/samples/browseable/RuntimePermissionsWear/Wearable/res/layout/rect_activity_main.xml new file mode 100644 index 000000000..5a44894a3 --- /dev/null +++ b/samples/browseable/RuntimePermissionsWear/Wearable/res/layout/rect_activity_main.xml @@ -0,0 +1,57 @@ + + + + + + + +