diff --git a/tools/txeddystone/TxEddystone/.gitignore b/tools/txeddystone/TxEddystone/.gitignore new file mode 100644 index 0000000..2fdf6dd --- /dev/null +++ b/tools/txeddystone/TxEddystone/.gitignore @@ -0,0 +1,7 @@ +.gradle +/local.properties +/.idea/ +.DS_Store +/build +/captures +*.iml diff --git a/tools/txeddystone/TxEddystone/app/.gitignore b/tools/txeddystone/TxEddystone/app/.gitignore new file mode 100644 index 0000000..796b96d --- /dev/null +++ b/tools/txeddystone/TxEddystone/app/.gitignore @@ -0,0 +1 @@ +/build diff --git a/tools/txeddystone/TxEddystone/app/build.gradle b/tools/txeddystone/TxEddystone/app/build.gradle new file mode 100644 index 0000000..5384c77 --- /dev/null +++ b/tools/txeddystone/TxEddystone/app/build.gradle @@ -0,0 +1,28 @@ +apply plugin: 'com.android.application' +apply from: '../variants.gradle' + +android { + compileSdkVersion 23 + buildToolsVersion "23.0.1" + + defaultConfig { + applicationId "com.google.sample.txeddystone" + minSdkVersion 21 + targetSdkVersion 23 + versionCode 1 + versionName "1.0" + } + buildTypes { + release { + minifyEnabled false + proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.pro' + } + } +} + +dependencies { + compile fileTree(dir: 'libs', include: ['*.jar']) + testCompile 'junit:junit:4.12' + compile 'com.android.support:appcompat-v7:23.1.0' + compile 'com.android.support:design:23.1.0' +} diff --git a/tools/txeddystone/TxEddystone/app/proguard-rules.pro b/tools/txeddystone/TxEddystone/app/proguard-rules.pro new file mode 100644 index 0000000..e822790 --- /dev/null +++ b/tools/txeddystone/TxEddystone/app/proguard-rules.pro @@ -0,0 +1,17 @@ +# Add project specific ProGuard rules here. +# By default, the flags in this file are appended to flags specified +# in /usr/local/google/home/mashbridge/android-studio-sdk/tools/proguard/proguard-android.txt +# You can edit the include path and order by changing the proguardFiles +# directive in build.gradle. +# +# For more details, see +# http://developer.android.com/guide/developing/tools/proguard.html + +# Add any project specific keep options here: + +# If your project uses WebView with JS, uncomment the following +# and specify the fully qualified class name to the JavaScript interface +# class: +#-keepclassmembers class fqcn.of.javascript.interface.for.webview { +# public *; +#} diff --git a/tools/txeddystone/TxEddystone/app/src/androidTest/java/com/google/sample/txeddystone/ApplicationTest.java b/tools/txeddystone/TxEddystone/app/src/androidTest/java/com/google/sample/txeddystone/ApplicationTest.java new file mode 100644 index 0000000..1f306a9 --- /dev/null +++ b/tools/txeddystone/TxEddystone/app/src/androidTest/java/com/google/sample/txeddystone/ApplicationTest.java @@ -0,0 +1,28 @@ +// Copyright 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.google.sample.txeddystone; + +import android.app.Application; +import android.test.ApplicationTestCase; + +/** + * Testing Fundamentals + */ +public class ApplicationTest extends ApplicationTestCase { + + public ApplicationTest() { + super(Application.class); + } +} \ No newline at end of file diff --git a/tools/txeddystone/TxEddystone/app/src/main/AndroidManifest.xml b/tools/txeddystone/TxEddystone/app/src/main/AndroidManifest.xml new file mode 100644 index 0000000..2c7b510 --- /dev/null +++ b/tools/txeddystone/TxEddystone/app/src/main/AndroidManifest.xml @@ -0,0 +1,29 @@ + + + + + + + + + + + + + + + + + + + + diff --git a/tools/txeddystone/TxEddystone/app/src/main/java/com/google/sample/txeddystone/MainActivity.java b/tools/txeddystone/TxEddystone/app/src/main/java/com/google/sample/txeddystone/MainActivity.java new file mode 100644 index 0000000..bf71739 --- /dev/null +++ b/tools/txeddystone/TxEddystone/app/src/main/java/com/google/sample/txeddystone/MainActivity.java @@ -0,0 +1,816 @@ +// Copyright 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.google.sample.txeddystone; + +import static com.google.sample.txeddystone.Utils.isValidHex; +import static com.google.sample.txeddystone.Utils.setEnabledViews; +import static com.google.sample.txeddystone.Utils.toByteArray; + +import android.app.Activity; +import android.app.AlertDialog; +import android.bluetooth.BluetoothAdapter; +import android.bluetooth.BluetoothManager; +import android.bluetooth.le.AdvertiseCallback; +import android.bluetooth.le.AdvertiseData; +import android.bluetooth.le.AdvertiseSettings; +import android.bluetooth.le.BluetoothLeAdvertiser; +import android.content.Context; +import android.content.DialogInterface; +import android.content.Intent; +import android.content.SharedPreferences; +import android.os.Handler; +import android.os.Looper; +import android.os.ParcelUuid; +import android.support.design.widget.TabLayout; +import android.support.v7.app.AppCompatActivity; +import android.support.v7.widget.Toolbar; +import android.support.v4.app.Fragment; +import android.support.v4.app.FragmentManager; +import android.support.v4.app.FragmentPagerAdapter; +import android.support.v4.view.ViewPager; +import android.os.Bundle; +import android.util.Log; +import android.view.LayoutInflater; +import android.view.View; +import android.view.View.OnClickListener; +import android.view.ViewGroup; +import android.widget.AdapterView; +import android.widget.AdapterView.OnItemSelectedListener; +import android.widget.ArrayAdapter; +import android.widget.Button; +import android.widget.CheckBox; +import android.widget.CompoundButton; +import android.widget.EditText; +import android.widget.LinearLayout; +import android.widget.Spinner; +import android.widget.Switch; +import android.widget.Toast; + +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.nio.ByteBuffer; + +/** + * Implements a tabbed layout of 4 BLE advertisers, each capable of broadcasting + * an Eddystone UID, TLM or URL frame, each with independent Tx power and mode. + */ +public class MainActivity extends AppCompatActivity { + private static final int REQUEST_ENABLE_BLUETOOTH = 1; + private static final int NUM_ADVERTISERS = 4; + private static final String SHARED_PREFS_NAME = "txeddystone-prefs"; + private static final String PREF_FRAME_TYPE = "pref-frame-type"; + private static final String PREF_TX_POWER = "pref-tx-power"; + private static final String PREF_TX_MODE = "pref-tx-mode"; + private static final String PREF_NAMESPACE = "pref-namespace"; + private static final String PREF_INSTANCE = "pref-instance"; + private static final String PREF_VOLTAGE = "pref-voltage"; + private static final String PREF_TEMPERATURE = "pref-temperature"; + private static final String PREF_ADVCNT = "pref-advcnt"; + private static final String PREF_SECCNT = "pref-seccnt"; + private static final String PREF_URL = "pref-url"; + private static final byte FRAME_TYPE_UID = 0x00; + private static final byte FRAME_TYPE_URL = 0x10; + private static final byte FRAME_TYPE_TLM = 0x20; + private static final ParcelUuid SERVICE_UUID = + ParcelUuid.fromString("0000FEAA-0000-1000-8000-00805F9B34FB"); + + /** + * The {@link android.support.v4.view.PagerAdapter} that will provide fragments for each of the + * sections. We use a {@link FragmentPagerAdapter} derivative, which will keep every loaded + * fragment in memory. If this becomes too memory intensive, it may be best to switch to a {@link + * android.support.v4.app.FragmentStatePagerAdapter}. + */ + private SectionsPagerAdapter mSectionsPagerAdapter; + + /** + * The {@link ViewPager} that will host the section contents. + */ + private ViewPager mViewPager; + + @Override + protected void onCreate(Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + setContentView(R.layout.activity_main); + + init(); + + Toolbar toolbar = (Toolbar) findViewById(R.id.toolbar); + setSupportActionBar(toolbar); + // Create the adapter that will return a fragment for each of the primary sections of the + // activity. + mSectionsPagerAdapter = new SectionsPagerAdapter(getSupportFragmentManager()); + + // Set up the ViewPager with the sections adapter and hold three of them in the pager's + // cache so the view state is easy to manage. + mViewPager = (ViewPager) findViewById(R.id.container); + mViewPager.setAdapter(mSectionsPagerAdapter); + mViewPager.setOffscreenPageLimit(NUM_ADVERTISERS - 1); + + TabLayout tabLayout = (TabLayout) findViewById(R.id.tabs); + tabLayout.setupWithViewPager(mViewPager); + } + + @Override + protected void onActivityResult(int requestCode, int resultCode, Intent data) { + super.onActivityResult(requestCode, resultCode, data); + if (requestCode == REQUEST_ENABLE_BLUETOOTH) { + if (resultCode == Activity.RESULT_OK) { + init(); + } else { + finish(); + } + } + } + + // Checks if Bluetooth advertising is supported on the device and requests enabling if necessary. + private void init() { + BluetoothManager manager = (BluetoothManager) getApplicationContext().getSystemService( + Context.BLUETOOTH_SERVICE); + BluetoothAdapter btAdapter = manager.getAdapter(); + if (btAdapter == null) { + showFinishingAlertDialog("Bluetooth Error", "Bluetooth not detected on device"); + } else if (!btAdapter.isEnabled()) { + Intent enableBtIntent = new Intent(BluetoothAdapter.ACTION_REQUEST_ENABLE); + this.startActivityForResult(enableBtIntent, REQUEST_ENABLE_BLUETOOTH); + } else if (!btAdapter.isMultipleAdvertisementSupported()) { + showFinishingAlertDialog("Not supported", "BLE advertising not supported on this device"); + } + } + + // Pops an AlertDialog that quits the app on OK. + private void showFinishingAlertDialog(String title, String message) { + new AlertDialog.Builder(this) + .setTitle(title) + .setMessage(message) + .setPositiveButton("OK", new DialogInterface.OnClickListener() { + @Override + public void onClick(DialogInterface dialogInterface, int i) { + finish(); + } + }).show(); + } + + /** + * A {@link FragmentPagerAdapter} that returns a fragment corresponding to one of the + * sections/tabs/pages. + */ + public class SectionsPagerAdapter extends FragmentPagerAdapter { + + public SectionsPagerAdapter(FragmentManager fm) { + super(fm); + } + + @Override + public Fragment getItem(int position) { + return AdvertiserFragment.newInstance(position + 1); + } + + @Override + public int getCount() { + return NUM_ADVERTISERS; + } + + @Override + public CharSequence getPageTitle(int position) { + switch (position) { + case 0: + return "ADV 1"; + case 1: + return "ADV 2"; + case 2: + return "ADV 3"; + case 3: + return "ADV 4"; + } + return null; + } + } + + /** + * A Fragment containing all the UI and logic to advertise one of the Eddystone frame types. + */ + public static class AdvertiserFragment extends Fragment { + private static final String TAG = AdvertiserFragment.class.getSimpleName(); + + // The fragment argument representing the section number for this fragment. + private static final String ADV_NUMBER = "adv_number"; + + // Each fragment has its own shared prefs where we store the most recently used values. + private SharedPreferences sharedPreferences; + + private static final Handler handler = new Handler(Looper.getMainLooper()); + + // The layouts for each frame type. + private LinearLayout uidLayout; + private LinearLayout tlmLayout; + private LinearLayout urlLayout; + + // Frame type selector. + private Spinner frameType; + + // Elements common to all frame types. + private Switch txSwitch; + private Spinner txPower; + private Spinner txMode; + + // UID fields. + private EditText namespace; + private Button rndNamespace; + private EditText instance; + private Button rndInstance; + + // TLM fields. + private EditText voltage; + private EditText temperature; + private EditText advCount; + private EditText secCount; + private CheckBox rotateCounters; + + // URL fields. + private EditText url; + + private int advNumber; + + // Used to set the spinners from shared preferences. + private String frameTypeValue; + private String txPowerValue; + private String txModeValue; + + private BluetoothLeAdvertiser advertiser; + private AdvertiseCallback advertiseCallback; + private boolean isAdvertising = false; + + /** + * Returns a new instance of this fragment for the given section number. + */ + public static AdvertiserFragment newInstance(int sectionNumber) { + AdvertiserFragment fragment = new AdvertiserFragment(); + Bundle args = new Bundle(); + args.putInt(ADV_NUMBER, sectionNumber); + fragment.setArguments(args); + return fragment; + } + + private String getPrefString(String key, int defaultResId) { + return sharedPreferences.getString(key, getString(defaultResId)); + } + + @Override + public void onSaveInstanceState(Bundle outState) { + super.onSaveInstanceState(outState); + stopAdvertising(); + } + + @Override + public void onCreate(Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + advNumber = getArguments().getInt(ADV_NUMBER); + sharedPreferences = getContext().getSharedPreferences(SHARED_PREFS_NAME + advNumber, 0); + + frameTypeValue = getPrefString(PREF_FRAME_TYPE, R.string.frame_type_uid); + txPowerValue = getPrefString(PREF_TX_POWER, R.string.tx_power_high); + txModeValue = getPrefString(PREF_TX_MODE, R.string.tx_mode_low_latency); + + BluetoothManager manager = (BluetoothManager) getContext().getSystemService( + Context.BLUETOOTH_SERVICE); + BluetoothAdapter btAdapter = manager.getAdapter(); + advertiser = btAdapter.getBluetoothLeAdvertiser(); + + advertiseCallback = new AdvertiseCallback() { + @Override + public void onStartSuccess(AdvertiseSettings settingsInEffect) { + isAdvertising = true; + } + + @Override + public void onStartFailure(int errorCode) { + switch (errorCode) { + case ADVERTISE_FAILED_DATA_TOO_LARGE: + showToastAndLogError("ADVERTISE_FAILED_DATA_TOO_LARGE"); + break; + case ADVERTISE_FAILED_TOO_MANY_ADVERTISERS: + showToastAndLogError("ADVERTISE_FAILED_TOO_MANY_ADVERTISERS"); + break; + case ADVERTISE_FAILED_ALREADY_STARTED: + showToastAndLogError("ADVERTISE_FAILED_ALREADY_STARTED"); + break; + case ADVERTISE_FAILED_INTERNAL_ERROR: + showToastAndLogError("ADVERTISE_FAILED_INTERNAL_ERROR"); + break; + case ADVERTISE_FAILED_FEATURE_UNSUPPORTED: + showToastAndLogError("ADVERTISE_FAILED_FEATURE_UNSUPPORTED"); + break; + default: + showToastAndLogError("startAdvertising failed with unknown error " + errorCode); + break; + } + } + }; + } + + @Override + public void onPause() { + super.onPause(); + + SharedPreferences.Editor editor = sharedPreferences.edit(); + editor.putString(PREF_FRAME_TYPE, frameTypeValue); + editor.putString(PREF_TX_POWER, txPowerValue); + editor.putString(PREF_TX_MODE, txModeValue); + editor.putString(PREF_NAMESPACE, namespace.getText().toString()); + editor.putString(PREF_INSTANCE, instance.getText().toString()); + editor.putString(PREF_VOLTAGE, voltage.getText().toString()); + editor.putString(PREF_TEMPERATURE, temperature.getText().toString()); + editor.putString(PREF_ADVCNT, advCount.getText().toString()); + editor.putString(PREF_SECCNT, secCount.getText().toString()); + editor.putString(PREF_URL, url.getText().toString()); + editor.apply(); + } + + @Override + public void onResume() { + super.onResume(); + } + + @Override + public View onCreateView(LayoutInflater inflater, ViewGroup container, + Bundle savedInstanceState) { + View rootView = inflater.inflate(R.layout.fragment_main, container, false); + + uidLayout = (LinearLayout) rootView.findViewById(R.id.uidLayout); + tlmLayout = (LinearLayout) rootView.findViewById(R.id.tlmLayout); + urlLayout = (LinearLayout) rootView.findViewById(R.id.urlLayout); + + frameType = (Spinner) rootView.findViewById(R.id.frameTypeSpinner); + ArrayAdapter frameTypeAdapter = ArrayAdapter.createFromResource( + AdvertiserFragment.this.getContext(), R.array.frame_type_array, + android.R.layout.simple_spinner_dropdown_item); + frameTypeAdapter.setDropDownViewResource(android.R.layout.simple_spinner_dropdown_item); + frameType.setAdapter(frameTypeAdapter); + for (int i = 0; i < frameType.getCount(); i++) { + if (frameType.getItemAtPosition(i).equals(frameTypeValue)) { + frameType.setSelection(i); + } + } + setFrameTypeSelectionListener(); + + txSwitch = (Switch) rootView.findViewById(R.id.txSwitch); + txSwitch.setOnCheckedChangeListener(new CompoundButton.OnCheckedChangeListener() { + @Override + public void onCheckedChanged(CompoundButton buttonView, boolean isChecked) { + if (isChecked) { + startAdvertising(); + } else { + stopAdvertising(); + handler.removeCallbacksAndMessages(null); + } + } + }); + + txPower = (Spinner) rootView.findViewById(R.id.txPower); + ArrayAdapter txPowerAdapter = ArrayAdapter.createFromResource( + AdvertiserFragment.this.getContext(), R.array.tx_power_array, + android.R.layout.simple_spinner_dropdown_item); + txPowerAdapter.setDropDownViewResource(android.R.layout.simple_spinner_dropdown_item); + txPower.setAdapter(txPowerAdapter); + for (int i = 0; i < txPower.getCount(); i++) { + if (txPower.getItemAtPosition(i).equals(txPowerValue)) { + txPower.setSelection(i); + } + } + setTxPowerSelectionListener(); + + txMode = (Spinner) rootView.findViewById(R.id.txMode); + ArrayAdapter txModeAdapter = ArrayAdapter.createFromResource( + AdvertiserFragment.this.getContext(), R.array.tx_mode_array, + android.R.layout.simple_spinner_dropdown_item); + txModeAdapter.setDropDownViewResource(android.R.layout.simple_spinner_dropdown_item); + txMode.setAdapter(txModeAdapter); + for (int i = 0; i < txMode.getCount(); i++) { + if (txMode.getItemAtPosition(i).equals(txModeValue)) { + txMode.setSelection(i); + } + } + setTxModeSelectionListener(); + + namespace = (EditText) rootView.findViewById(R.id.namespace); + namespace.setText(sharedPreferences.getString(PREF_NAMESPACE, "00000000000000000000")); + + rndNamespace = (Button) rootView.findViewById(R.id.randomizeNamespace); + rndNamespace.setOnClickListener(new OnClickListener() { + @Override + public void onClick(View view) { + namespace.setText(Utils.randomHexString(10)); + } + }); + + instance = (EditText) rootView.findViewById(R.id.instance); + instance.setText(sharedPreferences.getString(PREF_INSTANCE, "AAAAAAAAAAAA")); + + rndInstance = (Button) rootView.findViewById(R.id.randomizeInstance); + rndInstance.setOnClickListener(new OnClickListener() { + @Override + public void onClick(View view) { + instance.setText(Utils.randomHexString(6)); + } + }); + + voltage = (EditText) rootView.findViewById(R.id.voltage); + voltage.setText(sharedPreferences.getString(PREF_VOLTAGE, "2987")); + + temperature = (EditText) rootView.findViewById(R.id.temperature); + temperature.setText(sharedPreferences.getString(PREF_TEMPERATURE, "23.4")); + + advCount = (EditText) rootView.findViewById(R.id.advcnt); + advCount.setText(sharedPreferences.getString(PREF_ADVCNT, "123")); + + secCount = (EditText) rootView.findViewById(R.id.seccnt); + secCount.setText(sharedPreferences.getString(PREF_SECCNT, "456")); + + rotateCounters = (CheckBox) rootView.findViewById(R.id.rotateCounters); + + url = (EditText) rootView.findViewById(R.id.url); + url.setText(sharedPreferences.getString(PREF_URL, "https://www.google.co.uk")); + + return rootView; + } + + private void setFrameTypeSelectionListener() { + frameType.setOnItemSelectedListener(new OnItemSelectedListener() { + @Override + public void onItemSelected(AdapterView parent, View view, int position, long id) { + String selected = (String) parent.getItemAtPosition(position); + if (!selected.equals(frameTypeValue) && txSwitch.isChecked()) { + txSwitch.setChecked(false); + } + if (selected.equals(getString(R.string.frame_type_uid))) { + tlmLayout.setVisibility(View.GONE); + urlLayout.setVisibility(View.GONE); + uidLayout.setVisibility(View.VISIBLE); + } else if (selected.equals(getString(R.string.frame_type_tlm))) { + uidLayout.setVisibility(View.GONE); + urlLayout.setVisibility(View.GONE); + tlmLayout.setVisibility(View.VISIBLE); + } else if (selected.equals(getString(R.string.frame_type_url))) { + tlmLayout.setVisibility(View.GONE); + uidLayout.setVisibility(View.GONE); + urlLayout.setVisibility(View.VISIBLE); + } + frameTypeValue = selected; + } + + @Override + public void onNothingSelected(AdapterView adapterView) { + // NOP + } + }); + } + + private void setTxPowerSelectionListener() { + txPower.setOnItemSelectedListener(new OnItemSelectedListener() { + @Override + public void onItemSelected(AdapterView parent, View view, int position, long id) { + txPowerValue = (String) parent.getItemAtPosition(position); + } + + @Override + public void onNothingSelected(AdapterView parent) { + // NOP + } + }); + } + + private void setTxModeSelectionListener() { + txMode.setOnItemSelectedListener(new OnItemSelectedListener() { + @Override + public void onItemSelected(AdapterView parent, View view, int position, long id) { + txModeValue = (String) parent.getItemAtPosition(position); + } + + @Override + public void onNothingSelected(AdapterView parent) { + // NOP + } + }); + } + + private int txModeValueToSetting() { + if (txModeValue.equals(getString(R.string.tx_mode_low_latency))) { + return AdvertiseSettings.ADVERTISE_MODE_LOW_LATENCY; + } else if (txModeValue.equals(getString(R.string.tx_mode_balanced))) { + return AdvertiseSettings.ADVERTISE_MODE_BALANCED; + } else { + return AdvertiseSettings.ADVERTISE_MODE_LOW_POWER; + } + } + + // Returns a very rough approximation of the Tx mode's frequency. + private int txModeValueToHertz() { + if (txModeValue.equals(getString(R.string.tx_mode_low_latency))) { + return 7; + } else if (txModeValue.equals(getString(R.string.tx_mode_balanced))) { + return 3; + } else { + return 1; + } + } + + private int txPowerValueToSetting() { + if (txPowerValue.equals(getString(R.string.tx_power_high))) { + return AdvertiseSettings.ADVERTISE_TX_POWER_HIGH; + } else if (txPowerValue.equals(getString(R.string.tx_power_medium))) { + return AdvertiseSettings.ADVERTISE_TX_POWER_MEDIUM; + } else if (txPowerValue.equals(getString(R.string.tx_power_low))) { + return AdvertiseSettings.ADVERTISE_TX_POWER_LOW; + } else { + return AdvertiseSettings.ADVERTISE_TX_POWER_ULTRA_LOW; + } + } + + private void startAdvertising() { + if (isAdvertising) { + return; + } + AdvertiseSettings advertiseSettings = new AdvertiseSettings.Builder() + .setAdvertiseMode(txModeValueToSetting()) + .setTxPowerLevel(txPowerValueToSetting()) + .setConnectable(true) + .build(); + if (frameTypeValue.equals(getString(R.string.frame_type_uid))) { + startAdvertisingUri(advertiseSettings); + } else if (frameTypeValue.equals(getString(R.string.frame_type_tlm))) { + startAdvertisingTlm(advertiseSettings); + } else if (frameTypeValue.equals(getString(R.string.frame_type_url))) { + startAdvertisingUrl(advertiseSettings); + } else { + showToastAndLogError("Illegal frame type value " + frameTypeValue); + } + } + + private void startAdvertisingUri(AdvertiseSettings settings) { + Log.i(TAG, String.format("Start URI frame on ADV %d, Tx power %s, mode %s", + advNumber, txPower.getSelectedItem(), txMode.getSelectedItem())); + if (!isValidHex(namespace.getText().toString(), 10)) { + namespace.setError("not 10-byte hex"); + txSwitch.setChecked(false); + return; + } + if (!isValidHex(instance.getText().toString(), 6)) { + instance.setError("not 6-byte hex"); + txSwitch.setChecked(false); + return; + } + byte[] serviceData; + try { + serviceData = buildUidServiceData(); + } catch (IOException e) { + Log.e(TAG, e.toString()); + Toast.makeText(getContext(), "Failed to build service data", Toast.LENGTH_SHORT).show(); + txSwitch.setChecked(false); + return; + } + AdvertiseData advertiseData = new AdvertiseData.Builder() + .addServiceData(SERVICE_UUID, serviceData) + .addServiceUuid(SERVICE_UUID) + .setIncludeTxPowerLevel(false) + .setIncludeDeviceName(false) + .build(); + namespace.setError(null); + instance.setError(null); + toggleInputViews(false); + advertiser.startAdvertising(settings, advertiseData, advertiseCallback); + } + + // Converts the current Tx power level value to the byte value for that power + // in dBm at 0 meters. + // + // Note that this will vary by device and the values are only roughly accurate. + // The measurements were taken with a Nexus 6. + private byte txPowerValueToByte() { + if (txPowerValue.equals(getString(R.string.tx_power_high))) { + return (byte) -16; + } else if (txPowerValue.equals(getString(R.string.tx_power_medium))) { + return (byte) -26; + } else if (txPowerValue.equals(getString(R.string.tx_power_low))) { + return (byte) -35; + } else { + return (byte) -59; + } + } + + private byte[] buildUidServiceData() throws IOException { + byte[] namespaceBytes = toByteArray(namespace.getText().toString()); + byte[] instanceBytes = toByteArray(instance.getText().toString()); + ByteArrayOutputStream os = new ByteArrayOutputStream(); + os.write(new byte[]{FRAME_TYPE_UID, txPowerValueToByte()}); + os.write(namespaceBytes); + os.write(instanceBytes); + return os.toByteArray(); + } + + // TODO: for the TLM frame to be fully valid the advcnt and seccnt values should increment + // on every broadcast. The validation app will flag the current state as a bad frame. + private byte[] buildTlmServiceData(int millivolts, double temp, int advcnt, int seccnt) + throws IOException { + ByteBuffer buf = ByteBuffer.allocate(14); + buf.put(FRAME_TYPE_TLM); + buf.put((byte) 0x00); + buf.putShort((short) millivolts); + // Fixed-point 8.8 format. + short t = (short) (temp * 256.0f); + buf.put((byte) (t >> 8)); + buf.put((byte) (t & 0xff)); + buf.putInt(advcnt); + buf.putInt(seccnt); + return buf.array(); + } + + private byte[] buildUrlServiceData(String uri) throws IOException { + ByteArrayOutputStream os = new ByteArrayOutputStream(); + os.write(new byte[]{FRAME_TYPE_URL, txPowerValueToByte()}); + byte[] urlData = UrlUtils.encodeUri(uri); + if (urlData == null) { + showToastAndLogError("Could not encode URI " + uri); + } else { + os.write(urlData); + } + return os.toByteArray(); + } + + private void startAdvertisingTlm(final AdvertiseSettings settings) { + Log.i(TAG, String.format("Start TLM frame on ADV %d, Tx power %s, mode %s", + advNumber, txPower.getSelectedItem(), txMode.getSelectedItem())); + + final int millivolts; + try { + millivolts = Integer.parseInt(voltage.getText().toString()); + } catch (NumberFormatException e) { + Log.e(TAG, "Error parsing voltage to int", e); + voltage.setError("not an integer between 0 and 10000"); + txSwitch.setChecked(false); + return; + } + + final double temp; + try { + temp = Double.parseDouble(temperature.getText().toString()); + } catch (NumberFormatException e) { + Log.e(TAG, "Error parsing temperature to float", e); + temperature.setError("not a float between 0.0 and 60.0"); + txSwitch.setChecked(false); + return; + } + + final int advcnt; + try { + advcnt = Integer.parseInt(advCount.getText().toString()); + } catch (NumberFormatException e) { + Log.e(TAG, "Error parsing advCount to int", e); + advCount.setError("Not an integer"); + txSwitch.setChecked(false); + return; + } + + final int seccnt; + try { + seccnt = Integer.parseInt(secCount.getText().toString()); + } catch (NumberFormatException e) { + Log.e(TAG, "Error parsing secCount to int", e); + secCount.setError("Not an integer"); + txSwitch.setChecked(false); + return; + } + + byte[] serviceData; + try { + serviceData = buildTlmServiceData(millivolts, temp, advcnt, seccnt); + } catch (IOException e) { + Log.e(TAG, e.toString()); + Toast.makeText(getContext(), "Failed to build service data", Toast.LENGTH_SHORT).show(); + txSwitch.setChecked(false); + return; + } + + AdvertiseData advertiseData = new AdvertiseData.Builder() + .addServiceData(SERVICE_UUID, serviceData) + .addServiceUuid(SERVICE_UUID) + .setIncludeTxPowerLevel(false) + .setIncludeDeviceName(false) + .build(); + + voltage.setError(null); + temperature.setError(null); + advCount.setError(null); + secCount.setError(null); + toggleInputViews(false); + + // If checked we fake the stop/start so we can adjust the broadcast service data. We try to + // rotate at roughly the same rate as is selected in Tx Mode, + if (rotateCounters.isChecked()) { + final long delayMillis = 1000 / txModeValueToHertz(); + Runnable tlmRotation = new Runnable() { + @Override + public void run() { + stopAdvertising(); + byte[] newServiceData = null; + int adv = Integer.parseInt(advCount.getText().toString()) + 1; + int sec = Integer.parseInt(secCount.getText().toString()) + 1; + advCount.setText(Integer.toString(adv)); + secCount.setText(Integer.toString(sec)); + try { + newServiceData = buildTlmServiceData(millivolts, temp, adv, sec); + } catch (IOException e) { + showToastAndLogError(e.getMessage()); + } + Log.i(TAG, "restarting TLM ADV in loop"); + AdvertiseData newAdvertiseData = new AdvertiseData.Builder() + .addServiceData(SERVICE_UUID, newServiceData) + .addServiceUuid(SERVICE_UUID) + .setIncludeTxPowerLevel(false) + .setIncludeDeviceName(false) + .build(); + toggleInputViews(false); + advertiser.startAdvertising(settings, newAdvertiseData, advertiseCallback); + handler.postDelayed(this, delayMillis); + } + }; + handler.postDelayed(tlmRotation, delayMillis); + } else { + advertiser.startAdvertising(settings, advertiseData, advertiseCallback); + } + } + + private void startAdvertisingUrl(AdvertiseSettings settings) { + Log.i(TAG, String.format("Start URL frame %s on ADV %d, Tx power %s, mode %s", + url.getText(), advNumber, txPower.getSelectedItem(), txMode.getSelectedItem())); + + String urlString = url.getText().toString(); + byte[] uri = UrlUtils.encodeUri(urlString); + if (uri == null) { + url.setError("Invalid URL"); + txSwitch.setChecked(false); + return; + } + + if (uri.length > 18) { + url.setError("URL encodes to " + uri.length + " bytes, max is 18"); + txSwitch.setChecked(false); + return; + } + + byte[] serviceData; + try { + serviceData = buildUrlServiceData(urlString); + } catch (IOException e) { + Log.e(TAG, e.toString()); + Toast.makeText(getContext(), "Failed to build service data", Toast.LENGTH_SHORT).show(); + txSwitch.setChecked(false); + return; + } + + AdvertiseData advertiseData = new AdvertiseData.Builder() + .addServiceData(SERVICE_UUID, serviceData) + .addServiceUuid(SERVICE_UUID) + .setIncludeTxPowerLevel(false) + .setIncludeDeviceName(false) + .build(); + + url.setError(null); + toggleInputViews(false); + + advertiser.startAdvertising(settings, advertiseData, advertiseCallback); + } + + private void stopAdvertising() { + Log.i(TAG, String.format("Stop ADV %d", advNumber)); + advertiser.stopAdvertising(advertiseCallback); + isAdvertising = false; + toggleInputViews(true); + } + + private void toggleInputViews(boolean enable) { + setEnabledViews(enable, frameType, namespace, instance, rndNamespace, rndInstance, txPower, + txMode, voltage, temperature, advCount, secCount, url, rotateCounters); + } + + private void showToast(String message) { + Toast.makeText(getContext(), message, Toast.LENGTH_LONG).show(); + } + + private void showToastAndLogError(String message) { + showToast(message); + Log.e(TAG, message); + } + } +} diff --git a/tools/txeddystone/TxEddystone/app/src/main/java/com/google/sample/txeddystone/UrlUtils.java b/tools/txeddystone/TxEddystone/app/src/main/java/com/google/sample/txeddystone/UrlUtils.java new file mode 100644 index 0000000..ac31295 --- /dev/null +++ b/tools/txeddystone/TxEddystone/app/src/main/java/com/google/sample/txeddystone/UrlUtils.java @@ -0,0 +1,171 @@ +// Copyright 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.google.sample.txeddystone; + +import android.util.Log; +import android.util.SparseArray; +import android.webkit.URLUtil; + +import java.nio.ByteBuffer; +import java.nio.ByteOrder; +import java.util.Locale; +import java.util.UUID; + +/** + * Utils for broadcasting Eddystone-URL frames. + * + * Code mostly taken from: + * https://github.com/google/uribeacon/blob/uribeacon-final/android-uribeacon/uribeacon-library/ + */ +class UrlUtils { + + private static final String TAG = UrlUtils.class.getSimpleName(); + + /** + * Expansion strings for "http" and "https" schemes. These contain strings appearing anywhere in a + * URL. Restricted to Generic TLDs.

Note: this is a scheme specific encoding. + */ + private static final SparseArray URL_CODES = new SparseArray() {{ + put((byte) 0, ".com/"); + put((byte) 1, ".org/"); + put((byte) 2, ".edu/"); + put((byte) 3, ".net/"); + put((byte) 4, ".info/"); + put((byte) 5, ".biz/"); + put((byte) 6, ".gov/"); + put((byte) 7, ".com"); + put((byte) 8, ".org"); + put((byte) 9, ".edu"); + put((byte) 10, ".net"); + put((byte) 11, ".info"); + put((byte) 12, ".biz"); + put((byte) 13, ".gov"); + }}; + + /** + * URI Scheme maps a byte code into the scheme and an optional scheme specific prefix. + */ + private static final SparseArray URI_SCHEMES = new SparseArray() {{ + put((byte) 0, "http://www."); + put((byte) 1, "https://www."); + put((byte) 2, "http://"); + put((byte) 3, "https://"); + put((byte) 4, "urn:uuid:"); // RFC 2141 and RFC 4122}; + }}; + + /** + * Creates the Uri string with embedded expansion codes. + * + * @param uri to be encoded + * @return the Uri string with expansion codes. + */ + static byte[] encodeUri(String uri) { + if (uri.length() == 0) { + return new byte[0]; + } + ByteBuffer bb = ByteBuffer.allocate(uri.length()); + // UUIDs are ordered as byte array, which means most significant first + bb.order(ByteOrder.BIG_ENDIAN); + int position = 0; + + // Add the byte code for the scheme or return null if none + Byte schemeCode = encodeUriScheme(uri); + if (schemeCode == null) { + return null; + } + String scheme = URI_SCHEMES.get(schemeCode); + bb.put(schemeCode); + position += scheme.length(); + + if (URLUtil.isNetworkUrl(scheme)) { + return encodeUrl(uri, position, bb); + } else if ("urn:uuid:".equals(scheme)) { + return encodeUrnUuid(uri, position, bb); + } + return null; + } + + private static Byte encodeUriScheme(String uri) { + String lowerCaseUri = uri.toLowerCase(Locale.ENGLISH); + for (int i = 0; i < URI_SCHEMES.size(); i++) { + // get the key and value. + int key = URI_SCHEMES.keyAt(i); + String value = URI_SCHEMES.valueAt(i); + if (lowerCaseUri.startsWith(value)) { + return (byte) key; + } + } + return null; + } + + private static byte[] encodeUrl(String url, int position, ByteBuffer bb) { + while (position < url.length()) { + byte expansion = findLongestExpansion(url, position); + if (expansion >= 0) { + bb.put(expansion); + position += URL_CODES.get(expansion).length(); + } else { + bb.put((byte) url.charAt(position++)); + } + } + return byteBufferToArray(bb); + } + + private static byte[] encodeUrnUuid(String urn, int position, ByteBuffer bb) { + String uuidString = urn.substring(position, urn.length()); + UUID uuid; + try { + uuid = UUID.fromString(uuidString); + } catch (IllegalArgumentException e) { + Log.w(TAG, "encodeUrnUuid invalid urn:uuid format - " + urn); + return null; + } + // UUIDs are ordered as byte array, which means most significant first + bb.order(ByteOrder.BIG_ENDIAN); + bb.putLong(uuid.getMostSignificantBits()); + bb.putLong(uuid.getLeastSignificantBits()); + return byteBufferToArray(bb); + } + + /** + * Finds the longest expansion from the uri at the current position. + * + * @param uriString the Uri + * @param pos start position + * @return an index in URI_MAP or 0 if none. + */ + private static byte findLongestExpansion(String uriString, int pos) { + byte expansion = -1; + int expansionLength = 0; + for (int i = 0; i < URL_CODES.size(); i++) { + // get the key and value. + int key = URL_CODES.keyAt(i); + String value = URL_CODES.valueAt(i); + if (value.length() > expansionLength && uriString.startsWith(value, pos)) { + expansion = (byte) key; + expansionLength = value.length(); + } + } + return expansion; + } + + private static byte[] byteBufferToArray(ByteBuffer bb) { + byte[] bytes = new byte[bb.position()]; + bb.rewind(); + bb.get(bytes, 0, bytes.length); + return bytes; + } + +} diff --git a/tools/txeddystone/TxEddystone/app/src/main/java/com/google/sample/txeddystone/Utils.java b/tools/txeddystone/TxEddystone/app/src/main/java/com/google/sample/txeddystone/Utils.java new file mode 100644 index 0000000..d761fbd --- /dev/null +++ b/tools/txeddystone/TxEddystone/app/src/main/java/com/google/sample/txeddystone/Utils.java @@ -0,0 +1,71 @@ +// Copyright 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.google.sample.txeddystone; + +import android.view.View; + +import java.util.Random; + +class Utils { + + private Utils() {} // static functions only + + private static final char[] HEX = "0123456789ABCDEF".toCharArray(); + + static String toHexString(byte[] bytes) { + char[] chars = new char[bytes.length * 2]; + for (int i = 0; i < bytes.length; i++) { + int c = bytes[i] & 0xFF; + chars[i * 2] = HEX[c >>> 4]; + chars[i * 2 + 1] = HEX[c & 0x0F]; + } + return new String(chars).toLowerCase(); + } + + static boolean isValidHex(String s, int expectedLen) { + return !(s == null || s.isEmpty()) && (s.length() / 2) == expectedLen && s.matches("[0-9A-F]+"); + } + + static byte[] toByteArray(String hexString) { + // hexString guaranteed valid. + int len = hexString.length(); + byte[] bytes = new byte[len / 2]; + for (int i = 0; i < len; i += 2) { + bytes[i / 2] = (byte) ((Character.digit(hexString.charAt(i), 16) << 4) + + Character.digit(hexString.charAt(i + 1), 16)); + } + return bytes; + } + + static String randomHexString(int length) { + byte[] buf = new byte[length]; + new Random().nextBytes(buf); + StringBuilder stringBuilder = new StringBuilder(); + for (int i = 0; i < length; i++) { + stringBuilder.append(String.format("%02X", buf[i])); + } + return stringBuilder.toString(); + } + + static void setEnabledViews(boolean enabled, View... views) { + if (views == null || views.length == 0) { + return; + } + for (View v : views) { + v.setEnabled(enabled); + } + } + +} diff --git a/tools/txeddystone/TxEddystone/app/src/main/res/layout/activity_main.xml b/tools/txeddystone/TxEddystone/app/src/main/res/layout/activity_main.xml new file mode 100644 index 0000000..832a182 --- /dev/null +++ b/tools/txeddystone/TxEddystone/app/src/main/res/layout/activity_main.xml @@ -0,0 +1,41 @@ + + + + + + + + + + + + + + + + diff --git a/tools/txeddystone/TxEddystone/app/src/main/res/layout/fragment_main.xml b/tools/txeddystone/TxEddystone/app/src/main/res/layout/fragment_main.xml new file mode 100644 index 0000000..f0ee0b1 --- /dev/null +++ b/tools/txeddystone/TxEddystone/app/src/main/res/layout/fragment_main.xml @@ -0,0 +1,304 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +