From 4dd9dde6fbe4952124c5d18315d966da50c09652 Mon Sep 17 00:00:00 2001 From: root Date: Tue, 16 Jan 2018 18:24:22 +0100 Subject: [PATCH] =?UTF-8?q?close=20#26=20-=20outputstream=20implementiert,?= =?UTF-8?q?=20welche=20kontinuirliche=20daten=20schreibt=20close=20#24=20-?= =?UTF-8?q?=20k=C3=B6nnen=20jetzt=20ohne=20probleme=20kommentare=20schreib?= =?UTF-8?q?en=20close=20#22=20-=20startet=20beim=20frischen=20start=20aber?= =?UTF-8?q?=20auch=20beim=20onResume=20die=20phone=20app=20close=20#18=20-?= =?UTF-8?q?=20knallt=20nicht=20mehr=20wenn=20bei=200=20gestartet=20wird.?= =?UTF-8?q?=20close=20#9=20-=20verbinden=20sich=20wunderbar,=20nachrichten?= =?UTF-8?q?=20fliegen=20und=20daten=20werden=20=C3=BCbertragen=20close=20#?= =?UTF-8?q?8=20-=20sensordaten=20werden=20in=20ein=20.csv=20files=20geschr?= =?UTF-8?q?ieben=20inkl=20kommentare?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- android/ConductorsPhone/app/build.gradle | 11 +- .../app/src/main/AndroidManifest.xml | 4 + .../conductorswatch/DataFolder.java | 57 ++++ .../DataLayerListenerService.java | 46 ++- .../conductorswatch/MainActivity.java | 27 +- .../network/SensorDataCallback.java | 68 +++++ .../network/SensorDataFileReceiver.java | 111 +++++++ .../network/SensorDataFileWriter.java | 139 +++++++++ .../app/src/main/res/layout/activity_main.xml | 37 ++- .../app/src/main/res/raw/metronom.flac | Bin 0 -> 1175 bytes .../app/src/main/res/raw/metronom2.wav | Bin 0 -> 226 bytes .../app/src/main/res/raw/metronom3.wav | Bin 0 -> 448 bytes .../app/src/main/res/values/styles.xml | 2 +- android/ConductorsWatch/app/build.gradle | 5 +- .../tonifetzer/conductorswatch/Croller.java | 25 +- .../tonifetzer/conductorswatch/Estimator.java | 93 +++--- .../conductorswatch/MainActivity.java | 279 +++++++----------- .../tonifetzer/conductorswatch/Metronome.java | 8 +- .../de/tonifetzer/conductorswatch/TapBpm.java | 8 +- .../conductorswatch/WorkerFragment.java | 68 +++-- .../bpmEstimation/AccelerometerData.java | 7 + .../AccelerometerWindowBuffer.java | 33 ++- .../bpmEstimation/BpmEstimator.java | 9 +- .../network/SensorDataFileSender.java | 154 ++++++++++ .../network/SensorDataFileStreamer.java | 163 ++++++++++ ...{FileWriter.java => ByteStreamWriter.java} | 51 +++- .../app/src/main/res/layout/activity_main.xml | 2 +- 27 files changed, 1101 insertions(+), 306 deletions(-) create mode 100644 android/ConductorsPhone/app/src/main/java/de/tonifetzer/conductorswatch/DataFolder.java create mode 100644 android/ConductorsPhone/app/src/main/java/de/tonifetzer/conductorswatch/network/SensorDataCallback.java create mode 100644 android/ConductorsPhone/app/src/main/java/de/tonifetzer/conductorswatch/network/SensorDataFileReceiver.java create mode 100644 android/ConductorsPhone/app/src/main/java/de/tonifetzer/conductorswatch/network/SensorDataFileWriter.java create mode 100644 android/ConductorsPhone/app/src/main/res/raw/metronom.flac create mode 100644 android/ConductorsPhone/app/src/main/res/raw/metronom2.wav create mode 100644 android/ConductorsPhone/app/src/main/res/raw/metronom3.wav create mode 100644 android/ConductorsWatch/app/src/main/java/de/tonifetzer/conductorswatch/network/SensorDataFileSender.java create mode 100644 android/ConductorsWatch/app/src/main/java/de/tonifetzer/conductorswatch/network/SensorDataFileStreamer.java rename android/ConductorsWatch/app/src/main/java/de/tonifetzer/conductorswatch/utilities/{FileWriter.java => ByteStreamWriter.java} (51%) diff --git a/android/ConductorsPhone/app/build.gradle b/android/ConductorsPhone/app/build.gradle index d208ac1..993b6df 100644 --- a/android/ConductorsPhone/app/build.gradle +++ b/android/ConductorsPhone/app/build.gradle @@ -8,10 +8,11 @@ android { compileSdkVersion 26 defaultConfig { applicationId "de.tonifetzer.conductorswatch" - minSdkVersion 23 + minSdkVersion 24 targetSdkVersion 26 - versionCode 1 - versionName "1.0" + //sdk 2 | product version 3 | build num 2 | multi-apk 2 + versionCode 260120100 + versionName "0.1.2" testInstrumentationRunner "android.support.test.runner.AndroidJUnitRunner" } buildTypes { @@ -20,6 +21,10 @@ android { proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.pro' } } + compileOptions { + sourceCompatibility JavaVersion.VERSION_1_8 + targetCompatibility JavaVersion.VERSION_1_8 + } } dependencies { diff --git a/android/ConductorsPhone/app/src/main/AndroidManifest.xml b/android/ConductorsPhone/app/src/main/AndroidManifest.xml index 4bf6a67..4c4f298 100644 --- a/android/ConductorsPhone/app/src/main/AndroidManifest.xml +++ b/android/ConductorsPhone/app/src/main/AndroidManifest.xml @@ -2,6 +2,9 @@ + + + + diff --git a/android/ConductorsPhone/app/src/main/java/de/tonifetzer/conductorswatch/DataFolder.java b/android/ConductorsPhone/app/src/main/java/de/tonifetzer/conductorswatch/DataFolder.java new file mode 100644 index 0000000..1d4f446 --- /dev/null +++ b/android/ConductorsPhone/app/src/main/java/de/tonifetzer/conductorswatch/DataFolder.java @@ -0,0 +1,57 @@ +package de.tonifetzer.conductorswatch; + +/** + * Created by toni on 12/01/18. + */ + +import android.content.Context; +import android.os.Environment; +import android.util.Log; + +import java.io.File; + +/** + * Class: DataFolder + * Description: SDK save file class. Is able to open a folder on the device independent of the given + * device and android skd version. + */ +public class DataFolder { + + private File folder; + private static final String TAG = "DataFolder"; + + public DataFolder(Context context, String folderName){ + + // 1) try external data folder + folder = new File(context.getExternalFilesDir(Environment.DIRECTORY_DOCUMENTS), folderName); + if (isOK(folder)) {return;} + + // 2) try sd-card folder + folder = new File(Environment.getExternalStorageDirectory() + "/" + folderName); + if (isOK(folder)) {return;} + + // 3) try internal data folder + folder = new File(context.getApplicationInfo().dataDir); + if (isOK(folder)) {return;} + + // all failed + throw new RuntimeException("failed to create/access storage folder"); + + } + + /** ensure the given folder is OK */ + private static final boolean isOK(final File folder) { + folder.mkdirs(); + final boolean ok = folder.exists() && folder.isDirectory(); + if (ok) { + Log.d(TAG, "using: " + folder); + } else { + Log.d(TAG, "not OK: " + folder); + } + return ok; + } + + public File getFolder(){ + return folder; + } +} diff --git a/android/ConductorsPhone/app/src/main/java/de/tonifetzer/conductorswatch/DataLayerListenerService.java b/android/ConductorsPhone/app/src/main/java/de/tonifetzer/conductorswatch/DataLayerListenerService.java index 821daa3..755bc37 100644 --- a/android/ConductorsPhone/app/src/main/java/de/tonifetzer/conductorswatch/DataLayerListenerService.java +++ b/android/ConductorsPhone/app/src/main/java/de/tonifetzer/conductorswatch/DataLayerListenerService.java @@ -3,10 +3,17 @@ package de.tonifetzer.conductorswatch; import android.content.Intent; import android.util.Log; import com.google.android.gms.common.api.GoogleApiClient; +import com.google.android.gms.wearable.ChannelClient; 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.io.FileNotFoundException; +import java.io.FileOutputStream; +import java.io.IOException; +import java.io.InputStream; + /** * Listens to DataItems and Messages from the local node. */ @@ -14,6 +21,7 @@ public class DataLayerListenerService extends WearableListenerService { private static final String TAG = "DataLayerService"; private static final String START_ACTIVITY_PATH = "/start-activity"; + private static final String DATA_PATH ="/data"; GoogleApiClient mGoogleApiClient; @@ -28,7 +36,7 @@ public class DataLayerListenerService extends WearableListenerService { @Override public void onMessageReceived(MessageEvent messageEvent) { - //LOGD(TAG, "onMessageReceived: " + messageEvent); + LOGD(TAG, "onMessageReceived: " + messageEvent); // Check to see if the message is to start an activity if (messageEvent.getPath().equals(START_ACTIVITY_PATH)) { @@ -43,4 +51,40 @@ public class DataLayerListenerService extends WearableListenerService { Log.d(tag, message); } } + + @Override + public void onOutputClosed(ChannelClient.Channel openChannel, int i, int i1){ + + LOGD(TAG, "Channel successfully opened."); + + //get the sensordata provided by the watch + Wearable.getChannelClient(this).getInputStream(openChannel).addOnSuccessListener((InputStream stream) -> { + + Log.d(TAG, "Channel successfully opened and ready to write file."); + + byte[] data = null; + try { + stream.read(data); + DataFolder folder = new DataFolder(this, "sensorOutFiles"); + File file = new File(folder.getFolder(), System.currentTimeMillis() + ".csv"); + + try { + FileOutputStream fos = new FileOutputStream(file); + fos.write(data); + fos.close(); + + } catch (FileNotFoundException e) { + e.printStackTrace(); + } + + } catch (IOException e) { + e.printStackTrace(); + } + }).addOnFailureListener((Exception e) -> { + //Channel not open? couldn't receive inputstream? + }); + + //we have what we wanted, so close the channel + Wearable.getChannelClient(this).close(openChannel); + } } \ No newline at end of file diff --git a/android/ConductorsPhone/app/src/main/java/de/tonifetzer/conductorswatch/MainActivity.java b/android/ConductorsPhone/app/src/main/java/de/tonifetzer/conductorswatch/MainActivity.java index 735697b..b813237 100644 --- a/android/ConductorsPhone/app/src/main/java/de/tonifetzer/conductorswatch/MainActivity.java +++ b/android/ConductorsPhone/app/src/main/java/de/tonifetzer/conductorswatch/MainActivity.java @@ -1,22 +1,25 @@ package de.tonifetzer.conductorswatch; +import android.app.Activity; import android.graphics.Color; -import android.media.MediaPlayer; import android.support.v7.app.AppCompatActivity; import android.os.Bundle; import android.util.Log; -import android.view.SoundEffectConstants; +import android.widget.EditText; import android.widget.TextView; +import com.google.android.gms.wearable.ChannelClient; import com.google.android.gms.wearable.MessageEvent; import com.google.android.gms.wearable.MessageClient; import com.google.android.gms.wearable.Wearable; - import java.util.Timer; +import de.tonifetzer.conductorswatch.network.SensorDataCallback; + public class MainActivity extends AppCompatActivity implements MessageClient.OnMessageReceivedListener{ private TextView mTextView; - Timer mTimer; + private EditText mEditView; + private Timer mTimer; private static final String TAG = "DataLayerService"; private static final String START_ACTIVITY_PATH = "/start-activity"; @@ -26,27 +29,34 @@ public class MainActivity extends AppCompatActivity implements MessageClient.OnM private boolean mIsRecording = false; + private ChannelClient.ChannelCallback mChannelCallback; + @Override protected void onCreate(Bundle savedInstanceState) { super.onCreate(savedInstanceState); setContentView(R.layout.activity_main); - mTextView = (TextView) findViewById(R.id.bpmText); + mTextView = findViewById(R.id.bpmText); + mEditView = findViewById(R.id.comments); + + // handels everything regarding sensor data delivered by the watch. + mChannelCallback = new SensorDataCallback(this); } @Override public void onResume() { super.onResume(); Wearable.getMessageClient(this).addListener(this); + Wearable.getChannelClient(this).registerChannelCallback(mChannelCallback); } @Override protected void onPause() { super.onPause(); Wearable.getMessageClient(this).removeListener(this); + Wearable.getChannelClient(this).unregisterChannelCallback(mChannelCallback); } - @Override public void onMessageReceived(MessageEvent messageEvent) { @@ -67,6 +77,8 @@ public class MainActivity extends AppCompatActivity implements MessageClient.OnM mTimer = new Timer(); mTimer.scheduleAtFixedRate(new Metronome(this) , 0, 60000 / Integer.parseInt(parts[1])); mTextView.setTextColor(Color.parseColor("#EE693F")); + mEditView.setEnabled(false); + mEditView.setFocusable(false); mIsRecording = true; } @@ -75,6 +87,9 @@ public class MainActivity extends AppCompatActivity implements MessageClient.OnM mTimer.cancel(); mTextView.setTextColor(Color.parseColor("#158b69")); + mEditView.setEnabled(true); + mEditView.setFocusable(true); + mEditView.setFocusableInTouchMode(true); mIsRecording = false; } else if (messageEvent.getPath().contains(UPDATE_PATH)){ diff --git a/android/ConductorsPhone/app/src/main/java/de/tonifetzer/conductorswatch/network/SensorDataCallback.java b/android/ConductorsPhone/app/src/main/java/de/tonifetzer/conductorswatch/network/SensorDataCallback.java new file mode 100644 index 0000000..1f2ecd9 --- /dev/null +++ b/android/ConductorsPhone/app/src/main/java/de/tonifetzer/conductorswatch/network/SensorDataCallback.java @@ -0,0 +1,68 @@ +package de.tonifetzer.conductorswatch.network; + +import android.content.Context; +import android.support.annotation.NonNull; +import android.util.Log; + +import com.google.android.gms.wearable.ChannelClient; +import com.google.android.gms.wearable.Wearable; + +import java.io.InputStream; + +/** + * Created by toni on 14/01/18. + */ + +public class SensorDataCallback extends ChannelClient.ChannelCallback implements SensorDataFileReceiver.OnFileReceiveListener{ + + private static final String TAG = "SensorDataCallback"; + + private Context mContext; + private SensorDataFileReceiver mFileReceiver; + private ChannelClient.Channel mOpenChannel; + + public SensorDataCallback(Context context) { + mContext = context; + mOpenChannel = null; + + mFileReceiver = new SensorDataFileReceiver(mContext); + mFileReceiver.add(this); + } + + @Override + public void onChannelOpened(@NonNull ChannelClient.Channel openChannel) { + super.onChannelOpened(openChannel); + + mOpenChannel = openChannel; + Wearable.getChannelClient(mContext).getInputStream(mOpenChannel).addOnSuccessListener((InputStream stream) -> { + + mFileReceiver.setOpenStream(stream); + new Thread(mFileReceiver, "FileReceiverThread").start(); + }); + } + + @Override + public void onChannelClosed(@NonNull ChannelClient.Channel openChannel, int closeReason, int appSpecificErrorCode) { + super.onChannelClosed(openChannel, closeReason, appSpecificErrorCode); + + Log.d(TAG, "Channel is closed. Reason: " + closeReason + " - ErrorCode: " + appSpecificErrorCode); + } + + @Override + public void onStartSaveFile() { + //TODO: runOnUIThread stuff + + Log.d(TAG, "Start saving file. - " + Thread.currentThread().getName()); + } + + @Override + public void onFinishedSavingFile() { + + //close the channel + if(mOpenChannel != null){ + Wearable.getChannelClient(mContext).close(mOpenChannel); + } + + Log.d(TAG, "Finished saving file. - " + Thread.currentThread().getName()); + } +} diff --git a/android/ConductorsPhone/app/src/main/java/de/tonifetzer/conductorswatch/network/SensorDataFileReceiver.java b/android/ConductorsPhone/app/src/main/java/de/tonifetzer/conductorswatch/network/SensorDataFileReceiver.java new file mode 100644 index 0000000..db9526c --- /dev/null +++ b/android/ConductorsPhone/app/src/main/java/de/tonifetzer/conductorswatch/network/SensorDataFileReceiver.java @@ -0,0 +1,111 @@ +package de.tonifetzer.conductorswatch.network; + +import android.content.Context; +import android.util.Log; +import java.io.IOException; +import java.io.InputStream; +import java.nio.ByteBuffer; +import java.util.List; +import java.util.concurrent.CopyOnWriteArrayList; + +/** + * Created by toni on 14/01/18. + */ + +public class SensorDataFileReceiver implements Runnable { + + private InputStream mOpenStream; + private Context mContext; + private static final String TAG = "SensorDataFileReceiver"; + + public SensorDataFileReceiver(Context context){ + mOpenStream = null; + mContext = context; + } + + public void setOpenStream(InputStream stream){ + mOpenStream = stream; + } + + @Override + public void run() { + + if(mOpenStream != null){ + + Log.d(TAG, "Opened InputStream in Thread: " + Thread.currentThread().getName()); + + for(SensorDataFileReceiver.OnFileReceiveListener listener : listeners){ + listener.onStartSaveFile(); + } + + byte[] bytesTimestamp = new byte[Long.BYTES]; + byte[] bytesSensorID = new byte[Integer.BYTES]; + byte[] bytesX= new byte[Double.BYTES]; + byte[] bytesY= new byte[Double.BYTES]; + byte[] bytesZ= new byte[Double.BYTES]; + + long ts = 0; + int id = 0; + double x = 0, y = 0, z = 0; + + int i = 0; + SensorDataFileWriter fileWriter = new SensorDataFileWriter(mContext); + + try { + while(mOpenStream.read(bytesTimestamp) != -1) { + ts = ByteBuffer.wrap(bytesTimestamp).getLong(); + + mOpenStream.read(bytesSensorID); + id = ByteBuffer.wrap(bytesSensorID).getInt(); + + mOpenStream.read(bytesX); + x = ByteBuffer.wrap(bytesX).getDouble(); + + mOpenStream.read(bytesY); + y = ByteBuffer.wrap(bytesY).getDouble(); + + mOpenStream.read(bytesZ); + z = ByteBuffer.wrap(bytesZ).getDouble(); + + ++i; + + if(id != 4 && id != 10){ + Log.d(TAG, "STOOOOOP"); + } + + fileWriter.writeVector3D(ts, id, x, y, z); + } + + //we have what we wanted, so close the channel + fileWriter.toDisk(); + Log.d(TAG, "Num Data transferred: " + i); + + //finished callback + for(SensorDataFileReceiver.OnFileReceiveListener listener : listeners){ + listener.onFinishedSavingFile(); + } + + //close the stream, since this is recommended + mOpenStream.close(); + + } catch (IOException e) { + e.printStackTrace(); + } + } else { + Log.d(TAG, "Channel is not valid, is it really open? Check connection to watch! - Thread: " + Thread.currentThread().getName()); + } + } + + + /** + * Interface for callback + */ + public interface OnFileReceiveListener { + void onStartSaveFile(); + void onFinishedSavingFile(); + } + + private List listeners = new CopyOnWriteArrayList(); + public void add(SensorDataFileReceiver.OnFileReceiveListener listener){listeners.add(listener);} + public void remove(SensorDataFileReceiver.OnFileReceiveListener listener){listeners.remove(listener);} +} diff --git a/android/ConductorsPhone/app/src/main/java/de/tonifetzer/conductorswatch/network/SensorDataFileWriter.java b/android/ConductorsPhone/app/src/main/java/de/tonifetzer/conductorswatch/network/SensorDataFileWriter.java new file mode 100644 index 0000000..6780856 --- /dev/null +++ b/android/ConductorsPhone/app/src/main/java/de/tonifetzer/conductorswatch/network/SensorDataFileWriter.java @@ -0,0 +1,139 @@ +package de.tonifetzer.conductorswatch.network; + +import android.app.Activity; +import android.content.Context; +import android.os.AsyncTask; +import android.util.Log; +import android.widget.EditText; +import android.widget.TextView; + +import java.io.File; +import java.io.FileNotFoundException; +import java.io.FileOutputStream; +import java.io.IOException; + +import de.tonifetzer.conductorswatch.DataFolder; +import de.tonifetzer.conductorswatch.R; + +/** + * Created by toni on 14/01/18. + */ + +public class SensorDataFileWriter { + + private static final int FLUSH_LIMIT = 2*1024*1024; + private static final String TAG = "SensorDataFileWriter"; + + private File mSensorDataFile; + private FileOutputStream mFileOutputStream; + private StringBuilder mStringBuilder = new StringBuilder(); + private final DataFolder mFolder; + private boolean mStreamOpenend = false; + + public SensorDataFileWriter(Context context) { + + mFolder = new DataFolder(context, "sensorOutFiles"); + + //write some description for this file in the first line + EditText editView = ((Activity) context).findViewById(R.id.comments); + TextView textView = ((Activity) context).findViewById(R.id.bpmText); + writeDescription("Kommentar: " + editView.getText().toString() + "\nMetronom: " + textView.getText().toString() + " bpm"); + + //open connection to file + open(); + } + + public final void writeVector3D(long ts, int id, double x, double y, double z){ + + synchronized (this) { + + if (mStreamOpenend) { + + mStringBuilder.append(ts).append(';').append(id).append(';').append(x).append(';').append(y).append(';').append(z).append('\n'); + + if (mStringBuilder.length() > FLUSH_LIMIT) {flush(false);} + } else { + open(); + } + } + } + + private final void writeDescription(String str){ + synchronized (this) { + mStringBuilder.append(str).append('\n').append('\n'); + } + + } + + // flush data to disk + public final void toDisk(){ + synchronized (this) { + flush(true); + close(); + } + } + + /** helper method for exception-less writing. DO NOT CALL DIRECTLY! */ + private final void _write(final byte[] data) { + try { + mFileOutputStream.write(data); + Log.d(TAG, "flushed " + data.length + " bytes to disk"); + } catch (final Exception e) { + throw new RuntimeException("error while writing log-file", e); + } + } + + /** helper-class for background writing */ + class FlushAsync extends AsyncTask { + @Override + protected final Integer doInBackground(byte[][] data) { + _write(data[0]); + return null; + } + }; + + /** flush current buffer-contents to disk */ + private final void flush(boolean sync) { + + // fetch current buffer contents to write and hereafter empty the buffer + // this action MUST be atomic, just like the add-method + byte[] data = null; + synchronized (this) { + data = mStringBuilder.toString().getBytes(); // fetch data to write + mStringBuilder.setLength(0); // reset the buffer + } + + // write + if (sync) { + // write to disk using the current thread + _write(data); + } else { + // write to disk using a background-thread + new FlushAsync().execute(new byte[][] {data}); + } + } + + private final void open() { + mSensorDataFile = new File(mFolder.getFolder(), System.currentTimeMillis() + ".csv"); + + try { + mFileOutputStream = new FileOutputStream(mSensorDataFile); + mStreamOpenend = true; + + Log.d(TAG, "will write to: " + mSensorDataFile.toString()); + } catch (FileNotFoundException e) { + e.printStackTrace(); + + mStreamOpenend = false; + } + } + + private final void close() { + try { + mFileOutputStream.close(); + mStreamOpenend = false; + } catch (IOException e) { + e.printStackTrace(); + } + } +} diff --git a/android/ConductorsPhone/app/src/main/res/layout/activity_main.xml b/android/ConductorsPhone/app/src/main/res/layout/activity_main.xml index 3c2d36b..c454833 100644 --- a/android/ConductorsPhone/app/src/main/res/layout/activity_main.xml +++ b/android/ConductorsPhone/app/src/main/res/layout/activity_main.xml @@ -1,23 +1,38 @@ - + android:orientation="vertical" + tools:context="de.tonifetzer.conductorswatch.MainActivity" + android:gravity="center"> + android:textSize="100sp" + android:textStyle="bold" + android:layout_centerHorizontal="true"/> - + + + + + diff --git a/android/ConductorsPhone/app/src/main/res/raw/metronom.flac b/android/ConductorsPhone/app/src/main/res/raw/metronom.flac new file mode 100644 index 0000000000000000000000000000000000000000..fb19be03634a2ea787ee1f5b20dd18aa7f327e45 GIT binary patch literal 1175 zcmV;I1ZewaOkqO+001Ho01yBG1VI1_puZ~y=SlHb9W{Xtn%wt3ks>l#_i@q_>X zC?Eg;0CHt!WpZV1V`U(0X<|l9K|>%hE;BALATls9GcYwWHUIzs0RQ-O3;+RA`k)pkYEwE+9 z9Kn%SaNH;c00RI??%+JbD-;*O*jJzw$bt&L^P3Mqdj${VLF0L z|7#_17Mp+q)N}*IQzw>O6@%Ua81TcZrRyaa5ZgQlr>q7?n@|*JeRn7sw*nVY)?nXZG1T@m^w<&)oD43C z8!<^h4CC=IK~m`B98%}9pmgmpBkYh`;-v6Fjd`UHYbz9;hdRl_7K#IUKLnyIPC(eS zV6eu}(FlsOdITuikSp|{^q8rzwGv*s6D)Favy9Tq2rdE$Iv6#h;$~w{G>aY0GGrG_ z#~~bp7&dbVx-%()-!)cLV&17)NV4znV}FpcMM8b%CACbs2oXNaB$YYK5D`}l!ZaHQ zWN?*!Qh8)gv@3lbT&9~6Ggx&0le$1YtlVu9rYA+`ykCUD&UQeFz}opR-r zS-Ep0n~o+>DvKe9KoU3#*K`tYC)YN!B0-GL2qRTVU|UiVR>H$~{L-nw20#Lr9B}eu zkqCU5z*@Z?~2_p65ZYG$(qYy-Tf?#N_; zuL|3*&!m$0Uu3I03t@1fS}0j_r?2}KnVw--RFb4iYS8YRjR;Rip&+`C518!uN`jsu z3Q?GrbqGbNn^ig&{gfz}K?InzDdKvP0Vq5tmPi?|^NflXk=uDz zyNrlfFcgpC=`S>j_vkCrju{yWMTwJ`D0GZzi0JBwN$4dFirje0wTBqkAy$ccNUo-a z5QrXsh%ES(=Y<)#8#sptXn#0_LFgDCh5`6$or8%u6~KiJ=vdl<;b;>}gf%!xMS@H? z5U_)M$U7o|(?|}Ng4b9s{ef+G4CaDB2qjX13J3~Wfz4PPEr7>32?l{&NEMcVfT#wa zff0BP>wsPu1=xVWs0x69Ifw+4fQDEG`G6jH17v_$Xas(M2gm{qfIFB1M}Psi0e*lW p$N>a^0Js4WfDHHme}DzZ09b$rhyWpg0LTDofCLBtNq`2Reck33_|5aWXa%_O&$N-%WGy)F( zLs^sIA`A>Ei6w~|Kt)ggKmGq02-z4;{j+4~{nx}`_U{S9&cDTs8Glk(_WkqcNd3El zzws{D61_Qn&BYV;xA#o^(#<<@PESWaWACj#_g=fcqF%q=s9yHo>Ak+499`Az8@dd; zN-solerHr>J<4>EaV|3t6E~Y5!*$O0E2cH@^elL9-haNkzc0AQrEgP@OyBz+ z#y+K9`My)V)B5cC*7rsBt?J9~+t%0HcfPN>&#BL?&lf0`)|cH^*_YAR)iJ46`#$t3^(Xen^{4e0_Lue7_c!)~!1Df8{d@ZN_uuco*#EEpPQT-X!hX&E??Cf> zx{apEt>fBKe;}Jlf!&@}oplQfEAv*yDNLeF#Z2l -