diff --git a/GBDaoGenerator/src/nodomain/freeyourgadget/gadgetbridge/daogen/GBDaoGenerator.java b/GBDaoGenerator/src/nodomain/freeyourgadget/gadgetbridge/daogen/GBDaoGenerator.java index b6f5fdef6..11cda37c9 100644 --- a/GBDaoGenerator/src/nodomain/freeyourgadget/gadgetbridge/daogen/GBDaoGenerator.java +++ b/GBDaoGenerator/src/nodomain/freeyourgadget/gadgetbridge/daogen/GBDaoGenerator.java @@ -43,7 +43,7 @@ public class GBDaoGenerator { public static void main(String[] args) throws Exception { - final Schema schema = new Schema(37, MAIN_PACKAGE + ".entities"); + final Schema schema = new Schema(38, MAIN_PACKAGE + ".entities"); Entity userAttributes = addUserAttributes(schema); Entity user = addUserInfo(schema, userAttributes); @@ -87,6 +87,7 @@ public class GBDaoGenerator { addCalendarSyncState(schema, device); addAlarms(schema, user, device); addReminders(schema, user, device); + addWorldClocks(schema, user, device); Entity notificationFilter = addNotificationFilters(schema); @@ -561,6 +562,24 @@ public class GBDaoGenerator { reminder.addToOne(device, deviceId); } + private static void addWorldClocks(Schema schema, Entity user, Entity device) { + Entity worldClock = addEntity(schema, "WorldClock"); + worldClock.implementsInterface("nodomain.freeyourgadget.gadgetbridge.model.WorldClock"); + Property deviceId = worldClock.addLongProperty("deviceId").notNull().getProperty(); + Property userId = worldClock.addLongProperty("userId").notNull().getProperty(); + Property worldClockId = worldClock.addStringProperty("worldClockId").notNull().primaryKey().getProperty(); + Index indexUnique = new Index(); + indexUnique.addProperty(deviceId); + indexUnique.addProperty(userId); + indexUnique.addProperty(worldClockId); + indexUnique.makeUnique(); + worldClock.addIndex(indexUnique); + worldClock.addStringProperty("label").notNull(); + worldClock.addStringProperty("timeZoneId").notNull(); + worldClock.addToOne(user, userId); + worldClock.addToOne(device, deviceId); + } + private static void addNotificationFilterEntry(Schema schema, Entity notificationFilterEntity) { Entity notificatonFilterEntry = addEntity(schema, "NotificationFilterEntry"); notificatonFilterEntry.addIdProperty().autoincrement(); diff --git a/app/build.gradle b/app/build.gradle index eb6da978e..aff0035ab 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -238,6 +238,10 @@ dependencies { implementation 'com.google.protobuf:protobuf-lite:3.0.1' implementation "androidx.multidex:multidex:2.0.1" implementation 'com.android.volley:volley:1.2.1' + + // JSR-310 timezones backport for Android, since we're still on java 7 + implementation 'com.jakewharton.threetenabp:threetenabp:1.4.0' + testImplementation 'org.threeten:threetenbp:1.6.0' } preBuild.dependsOn(":GBDaoGenerator:genSources") diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index a2078b549..6a424dfb7 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -498,6 +498,10 @@ android:name=".activities.ConfigureReminders" android:label="@string/title_activity_set_reminders" android:parentActivityName=".activities.ControlCenterv2" /> + + . */ +package nodomain.freeyourgadget.gadgetbridge.activities; + +import android.app.AlertDialog; +import android.content.BroadcastReceiver; +import android.content.Context; +import android.content.DialogInterface; +import android.content.Intent; +import android.content.IntentFilter; +import android.os.Bundle; +import android.view.MenuItem; +import android.view.View; + +import androidx.annotation.NonNull; +import androidx.recyclerview.widget.LinearLayoutManager; +import androidx.recyclerview.widget.RecyclerView; + +import com.google.android.material.floatingactionbutton.FloatingActionButton; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.text.SimpleDateFormat; +import java.util.List; +import java.util.Locale; +import java.util.TimeZone; +import java.util.UUID; + +import nodomain.freeyourgadget.gadgetbridge.GBApplication; +import nodomain.freeyourgadget.gadgetbridge.R; +import nodomain.freeyourgadget.gadgetbridge.adapter.GBWorldClockListAdapter; +import nodomain.freeyourgadget.gadgetbridge.database.DBHandler; +import nodomain.freeyourgadget.gadgetbridge.database.DBHelper; +import nodomain.freeyourgadget.gadgetbridge.devices.DeviceCoordinator; +import nodomain.freeyourgadget.gadgetbridge.entities.DaoSession; +import nodomain.freeyourgadget.gadgetbridge.entities.Device; +import nodomain.freeyourgadget.gadgetbridge.entities.User; +import nodomain.freeyourgadget.gadgetbridge.entities.WorldClock; +import nodomain.freeyourgadget.gadgetbridge.impl.GBDevice; +import nodomain.freeyourgadget.gadgetbridge.util.DeviceHelper; + + +public class ConfigureWorldClocks extends AbstractGBActivity { + private static final Logger LOG = LoggerFactory.getLogger(ConfigureWorldClocks.class); + + private static final int REQ_CONFIGURE_WORLD_CLOCK = 1; + + private GBWorldClockListAdapter mGBWorldClockListAdapter; + private GBDevice gbDevice; + + private BroadcastReceiver timeTickBroadcastReceiver; + + @Override + protected void onCreate(Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + + setContentView(R.layout.activity_configure_world_clocks); + + gbDevice = getIntent().getParcelableExtra(GBDevice.EXTRA_DEVICE); + + mGBWorldClockListAdapter = new GBWorldClockListAdapter(this); + + final RecyclerView worldClocksRecyclerView = findViewById(R.id.world_clock_list); + worldClocksRecyclerView.setHasFixedSize(true); + worldClocksRecyclerView.setLayoutManager(new LinearLayoutManager(this)); + worldClocksRecyclerView.setAdapter(mGBWorldClockListAdapter); + updateWorldClocksFromDB(); + + final FloatingActionButton fab = findViewById(R.id.fab); + fab.setOnClickListener(new View.OnClickListener() { + @Override + public void onClick(View v) { + final DeviceCoordinator coordinator = DeviceHelper.getInstance().getCoordinator(gbDevice); + + int deviceSlots = coordinator.getWorldClocksSlotCount(); + + if (mGBWorldClockListAdapter.getItemCount() >= deviceSlots) { + // No more free slots + new AlertDialog.Builder(v.getContext()) + .setTitle(R.string.world_clock_no_free_slots_title) + .setMessage(getBaseContext().getString(R.string.world_clock_no_free_slots_description, String.format(Locale.getDefault(), "%d", deviceSlots))) + .setIcon(R.drawable.ic_warning) + .setPositiveButton(android.R.string.ok, new DialogInterface.OnClickListener() { + public void onClick(final DialogInterface dialog, final int whichButton) { + } + }) + .show(); + return; + } + + final WorldClock worldClock; + try (DBHandler db = GBApplication.acquireDB()) { + final DaoSession daoSession = db.getDaoSession(); + final Device device = DBHelper.getDevice(gbDevice, daoSession); + final User user = DBHelper.getUser(daoSession); + worldClock = createDefaultWorldClock(device, user); + } catch (final Exception e) { + LOG.error("Error accessing database", e); + return; + } + + configureWorldClock(worldClock); + } + }); + } + + @Override + protected void onStart() { + super.onStart(); + + timeTickBroadcastReceiver = new BroadcastReceiver() { + @Override + public void onReceive(final Context context, Intent intent) { + if (Intent.ACTION_TIME_TICK.equals(intent.getAction())) { + // Refresh the UI, to update the current time in each timezone + mGBWorldClockListAdapter.notifyDataSetChanged(); + } + } + }; + + registerReceiver(timeTickBroadcastReceiver, new IntentFilter(Intent.ACTION_TIME_TICK)); + } + + @Override + protected void onStop() { + super.onStop(); + + if (timeTickBroadcastReceiver != null) { + unregisterReceiver(timeTickBroadcastReceiver); + } + } + + @Override + protected void onResume() { + super.onResume(); + // Refresh to update the current time on each clock + mGBWorldClockListAdapter.notifyDataSetChanged(); + } + + @Override + protected void onActivityResult(int requestCode, int resultCode, Intent data) { + super.onActivityResult(requestCode, resultCode, data); + + if (requestCode == REQ_CONFIGURE_WORLD_CLOCK && resultCode == 1) { + updateWorldClocksFromDB(); + sendWorldClocksToDevice(); + } + } + + private WorldClock createDefaultWorldClock(@NonNull Device device, @NonNull User user) { + final WorldClock worldClock = new WorldClock(); + final String timezone = TimeZone.getDefault().getID(); + worldClock.setTimeZoneId(timezone); + final String[] timezoneParts = timezone.split("/"); + worldClock.setLabel(timezoneParts[timezoneParts.length - 1]); + + worldClock.setDeviceId(device.getId()); + worldClock.setUserId(user.getId()); + worldClock.setWorldClockId(UUID.randomUUID().toString()); + + return worldClock; + } + + /** + * Reads the available worldClocks from the database and updates the view afterwards. + */ + private void updateWorldClocksFromDB() { + final List worldClocks = DBHelper.getWorldClocks(gbDevice); + + mGBWorldClockListAdapter.setWorldClockList(worldClocks); + mGBWorldClockListAdapter.notifyDataSetChanged(); + } + + @Override + public boolean onOptionsItemSelected(MenuItem item) { + switch (item.getItemId()) { + case android.R.id.home: + // back button + finish(); + return true; + } + return super.onOptionsItemSelected(item); + } + + public void configureWorldClock(final WorldClock worldClock) { + final Intent startIntent = new Intent(getApplicationContext(), WorldClockDetails.class); + startIntent.putExtra(GBDevice.EXTRA_DEVICE, gbDevice); + startIntent.putExtra(WorldClock.EXTRA_WORLD_CLOCK, worldClock); + startActivityForResult(startIntent, REQ_CONFIGURE_WORLD_CLOCK); + } + + public void deleteWorldClock(final WorldClock worldClock) { + DBHelper.delete(worldClock); + updateWorldClocksFromDB(); + sendWorldClocksToDevice(); + } + + private void sendWorldClocksToDevice() { + if (gbDevice.isInitialized()) { + GBApplication.deviceService().onSetWorldClocks(mGBWorldClockListAdapter.getWorldClockList()); + } + } +} diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/WorldClockDetails.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/WorldClockDetails.java new file mode 100644 index 000000000..a1f3c5646 --- /dev/null +++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/WorldClockDetails.java @@ -0,0 +1,174 @@ +/* Copyright (C) 2022 José Rebelo + + This file is part of Gadgetbridge. + + Gadgetbridge is free software: you can redistribute it and/or modify + it under the terms of the GNU Affero General Public License as published + by the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + Gadgetbridge is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU Affero General Public License for more details. + + You should have received a copy of the GNU Affero General Public License + along with this program. If not, see . */ +package nodomain.freeyourgadget.gadgetbridge.activities; + +import android.app.AlertDialog; +import android.content.DialogInterface; +import android.os.Bundle; +import android.text.Editable; +import android.text.InputFilter; +import android.text.TextWatcher; +import android.view.MenuItem; +import android.view.View; +import android.widget.ArrayAdapter; +import android.widget.EditText; +import android.widget.TextView; +import android.widget.Toast; + +import com.google.android.material.floatingactionbutton.FloatingActionButton; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.util.TimeZone; + +import nodomain.freeyourgadget.gadgetbridge.R; +import nodomain.freeyourgadget.gadgetbridge.database.DBHelper; +import nodomain.freeyourgadget.gadgetbridge.devices.DeviceCoordinator; +import nodomain.freeyourgadget.gadgetbridge.entities.WorldClock; +import nodomain.freeyourgadget.gadgetbridge.impl.GBDevice; +import nodomain.freeyourgadget.gadgetbridge.util.DeviceHelper; +import nodomain.freeyourgadget.gadgetbridge.util.GB; + +public class WorldClockDetails extends AbstractGBActivity { + private static final Logger LOG = LoggerFactory.getLogger(WorldClockDetails.class); + + private WorldClock worldClock; + private GBDevice device; + + ArrayAdapter timezoneAdapter; + + TextView worldClockTimezone; + EditText worldClockLabel; + + @Override + protected void onCreate(Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + setContentView(R.layout.activity_world_clock_details); + + worldClock = (WorldClock) getIntent().getSerializableExtra(WorldClock.EXTRA_WORLD_CLOCK); + + if (worldClock == null) { + GB.toast("No worldClock provided to WorldClockDetails Activity", Toast.LENGTH_LONG, GB.ERROR); + finish(); + return; + } + + worldClockTimezone = findViewById(R.id.world_clock_timezone); + worldClockLabel = findViewById(R.id.world_clock_label); + + device = getIntent().getParcelableExtra(GBDevice.EXTRA_DEVICE); + final DeviceCoordinator coordinator = DeviceHelper.getInstance().getCoordinator(device); + + final String[] timezoneIDs = TimeZone.getAvailableIDs(); + timezoneAdapter = new ArrayAdapter<>(this, android.R.layout.simple_list_item_1, timezoneIDs); + + final View cardTimezone = findViewById(R.id.card_timezone); + cardTimezone.setOnClickListener(new View.OnClickListener() { + @Override + public void onClick(View view) { + new AlertDialog.Builder(WorldClockDetails.this).setAdapter(timezoneAdapter, new DialogInterface.OnClickListener() { + @Override + public void onClick(DialogInterface dialogInterface, int i) { + worldClock.setTimeZoneId(timezoneIDs[i]); + updateUiFromWorldClock(); + } + }).create().show(); + } + }); + + worldClockLabel.setFilters(new InputFilter[]{new InputFilter.LengthFilter(coordinator.getWorldClocksLabelLength())}); + worldClockLabel.addTextChangedListener(new TextWatcher() { + @Override + public void beforeTextChanged(final CharSequence s, int start, int count, int after) { + } + + @Override + public void onTextChanged(final CharSequence s, int start, int before, int count) { + } + + @Override + public void afterTextChanged(final Editable s) { + worldClock.setLabel(s.toString()); + } + }); + + final FloatingActionButton fab = findViewById(R.id.fab_save); + fab.setOnClickListener(new View.OnClickListener() { + @Override + public void onClick(View view) { + updateWorldClock(); + WorldClockDetails.this.setResult(1); + finish(); + } + }); + + updateUiFromWorldClock(); + } + + @Override + public boolean onOptionsItemSelected(MenuItem item) { + switch (item.getItemId()) { + case android.R.id.home: + // back button + // TODO confirm when exiting without saving + finish(); + return true; + } + return super.onOptionsItemSelected(item); + } + + private void updateWorldClock() { + DBHelper.store(worldClock); + } + + @Override + protected void onSaveInstanceState(Bundle state) { + super.onSaveInstanceState(state); + state.putSerializable("worldClock", worldClock); + } + + @Override + protected void onRestoreInstanceState(Bundle savedInstanceState) { + super.onRestoreInstanceState(savedInstanceState); + worldClock = (WorldClock) savedInstanceState.getSerializable("worldClock"); + updateUiFromWorldClock(); + } + + public void updateUiFromWorldClock() { + final String oldTimezone = worldClockTimezone.getText().toString(); + + worldClockTimezone.setText(worldClock.getTimeZoneId()); + + // Check if the label was still the default (the timezone city name) + // If so, and if the user changed the timezone, update the label to match the new city name + if (!oldTimezone.equals(worldClock.getTimeZoneId())) { + final String[] oldTimezoneParts = oldTimezone.split("/"); + final String[] newTimezoneParts = worldClock.getTimeZoneId().split("/"); + final String newLabel = newTimezoneParts[newTimezoneParts.length - 1]; + final String oldLabel = oldTimezoneParts[oldTimezoneParts.length - 1]; + final String userLabel = worldClockLabel.getText().toString(); + + if (userLabel.equals(oldLabel)) { + // The label was still the original, so let's override it with the new city + worldClock.setLabel(newLabel); + } + } + + worldClockLabel.setText(worldClock.getLabel()); + } +} diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/devicesettings/DeviceSettingsActivity.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/devicesettings/DeviceSettingsActivity.java index a34fde8bb..9ed08de5d 100644 --- a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/devicesettings/DeviceSettingsActivity.java +++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/devicesettings/DeviceSettingsActivity.java @@ -48,20 +48,7 @@ public class DeviceSettingsActivity extends AbstractGBActivity implements if (savedInstanceState == null) { Fragment fragment = getSupportFragmentManager().findFragmentByTag(DeviceSpecificSettingsFragment.FRAGMENT_TAG); if (fragment == null) { - DeviceCoordinator coordinator = DeviceHelper.getInstance().getCoordinator(device); - int[] supportedSettings = coordinator.getSupportedDeviceSpecificSettings(device); - String[] supportedLanguages = coordinator.getSupportedLanguageSettings(device); - - if (supportedLanguages != null) { - supportedSettings = ArrayUtils.insert(0, supportedSettings, R.xml.devicesettings_language_generic); - } - if (coordinator.supportsActivityTracking()) { - supportedSettings = ArrayUtils.addAll(supportedSettings, R.xml.devicesettings_chartstabs); - supportedSettings = ArrayUtils.addAll(supportedSettings, R.xml.devicesettings_device_card_activity_card_preferences); - } - - final DeviceSpecificSettingsCustomizer deviceSpecificSettingsCustomizer = coordinator.getDeviceSpecificSettingsCustomizer(device); - fragment = DeviceSpecificSettingsFragment.newInstance(device.getAddress(), supportedSettings, supportedLanguages, deviceSpecificSettingsCustomizer); + fragment = DeviceSpecificSettingsFragment.newInstance(device); } getSupportFragmentManager() .beginTransaction() @@ -73,21 +60,7 @@ public class DeviceSettingsActivity extends AbstractGBActivity implements @Override public boolean onPreferenceStartScreen(PreferenceFragmentCompat caller, PreferenceScreen preferenceScreen) { - DeviceCoordinator coordinator = DeviceHelper.getInstance().getCoordinator(device); - int[] supportedSettings = coordinator.getSupportedDeviceSpecificSettings(device); - String[] supportedLanguages = coordinator.getSupportedLanguageSettings(device); - - if (supportedLanguages != null) { - supportedSettings = ArrayUtils.insert(0, supportedSettings, R.xml.devicesettings_language_generic); - } - - if (coordinator.supportsActivityTracking()) { - supportedSettings = ArrayUtils.addAll(supportedSettings, R.xml.devicesettings_chartstabs); - supportedSettings = ArrayUtils.addAll(supportedSettings, R.xml.devicesettings_device_card_activity_card_preferences); - } - - final DeviceSpecificSettingsCustomizer deviceSpecificSettingsCustomizer = coordinator.getDeviceSpecificSettingsCustomizer(device); - PreferenceFragmentCompat fragment = DeviceSpecificSettingsFragment.newInstance(device.getAddress(), supportedSettings, supportedLanguages, deviceSpecificSettingsCustomizer); + final PreferenceFragmentCompat fragment = DeviceSpecificSettingsFragment.newInstance(device); Bundle args = fragment.getArguments(); args.putString(PreferenceFragmentCompat.ARG_PREFERENCE_ROOT, preferenceScreen.getKey()); fragment.setArguments(args); diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/devicesettings/DeviceSettingsPreferenceConst.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/devicesettings/DeviceSettingsPreferenceConst.java index 1f82f62e6..39964bdcd 100644 --- a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/devicesettings/DeviceSettingsPreferenceConst.java +++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/devicesettings/DeviceSettingsPreferenceConst.java @@ -112,6 +112,8 @@ public class DeviceSettingsPreferenceConst { public static final String PREF_KEY_VIBRATION = "key_vibration"; public static final String PREF_FAKE_RING_DURATION = "fake_ring_duration"; + public static final String PREF_WORLD_CLOCKS = "pref_world_clocks"; + public static final String PREF_ANTILOST_ENABLED = "pref_antilost_enabled"; public static final String PREF_HYDRATION_SWITCH = "pref_hydration_switch"; public static final String PREF_HYDRATION_PERIOD = "pref_hydration_period"; diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/devicesettings/DeviceSpecificSettingsFragment.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/devicesettings/DeviceSpecificSettingsFragment.java index 5f817cc9d..4bbd36b6a 100644 --- a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/devicesettings/DeviceSpecificSettingsFragment.java +++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/devicesettings/DeviceSpecificSettingsFragment.java @@ -20,6 +20,7 @@ import android.content.Intent; import android.content.SharedPreferences; import android.os.Bundle; import android.text.InputType; +import android.view.View; import android.widget.EditText; import androidx.annotation.NonNull; @@ -47,11 +48,17 @@ import java.util.Objects; import nodomain.freeyourgadget.gadgetbridge.GBApplication; import nodomain.freeyourgadget.gadgetbridge.R; +import nodomain.freeyourgadget.gadgetbridge.activities.ConfigureWorldClocks; +import nodomain.freeyourgadget.gadgetbridge.activities.SettingsActivity; +import nodomain.freeyourgadget.gadgetbridge.devices.DeviceCoordinator; import nodomain.freeyourgadget.gadgetbridge.devices.DeviceManager; import nodomain.freeyourgadget.gadgetbridge.devices.huami.HuamiConst; import nodomain.freeyourgadget.gadgetbridge.devices.makibeshr3.MakibesHR3Constants; import nodomain.freeyourgadget.gadgetbridge.devices.miband.MiBandConst; +import nodomain.freeyourgadget.gadgetbridge.devices.miband.MiBandPreferencesActivity; +import nodomain.freeyourgadget.gadgetbridge.impl.GBDevice; import nodomain.freeyourgadget.gadgetbridge.model.CannedMessagesSpec; +import nodomain.freeyourgadget.gadgetbridge.util.DeviceHelper; import nodomain.freeyourgadget.gadgetbridge.util.Prefs; import nodomain.freeyourgadget.gadgetbridge.util.XTimePreference; import nodomain.freeyourgadget.gadgetbridge.util.XTimePreferenceFragment; @@ -89,6 +96,8 @@ public class DeviceSpecificSettingsFragment extends PreferenceFragmentCompat imp private DeviceSpecificSettingsCustomizer deviceSpecificSettingsCustomizer; + private GBDevice device; + private void setSettingsFileSuffix(String settingsFileSuffix, @NonNull int[] supportedSettings, String[] supportedLanguages) { Bundle args = new Bundle(); args.putString("settingsFileSuffix", settingsFileSuffix); @@ -103,6 +112,12 @@ public class DeviceSpecificSettingsFragment extends PreferenceFragmentCompat imp setArguments(args); } + private void setDevice(final GBDevice device) { + final Bundle args = getArguments() != null ? getArguments() : new Bundle(); + args.putParcelable("device", device); + setArguments(args); + } + @Override public void onCreatePreferences(Bundle savedInstanceState, String rootKey) { Bundle arguments = getArguments(); @@ -113,6 +128,7 @@ public class DeviceSpecificSettingsFragment extends PreferenceFragmentCompat imp int[] supportedSettings = arguments.getIntArray("supportedSettings"); String[] supportedLanguages = arguments.getStringArray("supportedLanguages"); this.deviceSpecificSettingsCustomizer = arguments.getParcelable("deviceSpecificSettingsCustomizer"); + this.device = arguments.getParcelable("device"); if (settingsFileSuffix == null || supportedSettings == null) { return; @@ -587,6 +603,19 @@ public class DeviceSpecificSettingsFragment extends PreferenceFragmentCompat imp }); } + final Preference worldClocks = findPreference(PREF_WORLD_CLOCKS); + if (worldClocks != null) { + worldClocks.setOnPreferenceClickListener(new Preference.OnPreferenceClickListener() { + @Override + public boolean onPreferenceClick(Preference preference) { + final Intent intent = new Intent(getContext(), ConfigureWorldClocks.class); + intent.putExtra(GBDevice.EXTRA_DEVICE, device); + startActivity(intent); + return true; + } + }); + } + final Preference cannedMessagesDismissCall = findPreference("canned_messages_dismisscall_send"); if (cannedMessagesDismissCall != null) { cannedMessagesDismissCall.setOnPreferenceClickListener(new androidx.preference.Preference.OnPreferenceClickListener() { @@ -704,13 +733,28 @@ public class DeviceSpecificSettingsFragment extends PreferenceFragmentCompat imp } } - static DeviceSpecificSettingsFragment newInstance(String settingsFileSuffix, - @NonNull int[] supportedSettings, - String[] supportedLanguages, - DeviceSpecificSettingsCustomizer deviceSpecificSettingsCustomizer) { + static DeviceSpecificSettingsFragment newInstance(GBDevice device) { + final DeviceCoordinator coordinator = DeviceHelper.getInstance().getCoordinator(device); + int[] supportedSettings = coordinator.getSupportedDeviceSpecificSettings(device); + String[] supportedLanguages = coordinator.getSupportedLanguageSettings(device); + + if (supportedLanguages != null) { + supportedSettings = ArrayUtils.insert(0, supportedSettings, R.xml.devicesettings_language_generic); + } + + if (coordinator.supportsActivityTracking()) { + supportedSettings = ArrayUtils.addAll(supportedSettings, R.xml.devicesettings_chartstabs); + supportedSettings = ArrayUtils.addAll(supportedSettings, R.xml.devicesettings_device_card_activity_card_preferences); + } + + final DeviceSpecificSettingsCustomizer deviceSpecificSettingsCustomizer = coordinator.getDeviceSpecificSettingsCustomizer(device); + + final String settingsFileSuffix = device.getAddress(); + final DeviceSpecificSettingsFragment fragment = new DeviceSpecificSettingsFragment(); fragment.setSettingsFileSuffix(settingsFileSuffix, supportedSettings, supportedLanguages); fragment.setDeviceSpecificSettingsCustomizer(deviceSpecificSettingsCustomizer); + fragment.setDevice(device); return fragment; } diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/adapter/GBWorldClockListAdapter.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/adapter/GBWorldClockListAdapter.java new file mode 100644 index 000000000..82f07cde9 --- /dev/null +++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/adapter/GBWorldClockListAdapter.java @@ -0,0 +1,132 @@ +/* Copyright (C) 2022 José Rebelo + + This file is part of Gadgetbridge. + + Gadgetbridge is free software: you can redistribute it and/or modify + it under the terms of the GNU Affero General Public License as published + by the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + Gadgetbridge is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU Affero General Public License for more details. + + You should have received a copy of the GNU Affero General Public License + along with this program. If not, see . */ +package nodomain.freeyourgadget.gadgetbridge.adapter; + + +import android.app.AlertDialog; +import android.content.Context; +import android.content.DialogInterface; +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; +import android.widget.TextView; + +import androidx.annotation.NonNull; +import androidx.cardview.widget.CardView; +import androidx.recyclerview.widget.RecyclerView; + +import java.text.DateFormat; +import java.text.SimpleDateFormat; +import java.util.ArrayList; +import java.util.Date; +import java.util.List; +import java.util.Locale; +import java.util.TimeZone; + +import nodomain.freeyourgadget.gadgetbridge.GBApplication; +import nodomain.freeyourgadget.gadgetbridge.R; +import nodomain.freeyourgadget.gadgetbridge.activities.ConfigureWorldClocks; +import nodomain.freeyourgadget.gadgetbridge.entities.WorldClock; + +/** + * Adapter for displaying WorldClock instances. + */ +public class GBWorldClockListAdapter extends RecyclerView.Adapter { + + private final Context mContext; + private ArrayList worldClockList; + + public GBWorldClockListAdapter(final Context context) { + this.mContext = context; + } + + public void setWorldClockList(final List worldClocks) { + this.worldClockList = new ArrayList<>(worldClocks); + } + + public ArrayList getWorldClockList() { + return worldClockList; + } + + @NonNull + @Override + public GBWorldClockListAdapter.ViewHolder onCreateViewHolder(@NonNull final ViewGroup parent, final int viewType) { + final View view = LayoutInflater.from(parent.getContext()).inflate(R.layout.item_world_clock, parent, false); + return new ViewHolder(view); + } + + @Override + public void onBindViewHolder(@NonNull ViewHolder holder, final int position) { + final WorldClock worldClock = worldClockList.get(position); + + holder.container.setOnClickListener(new View.OnClickListener() { + @Override + public void onClick(View v) { + ((ConfigureWorldClocks) mContext).configureWorldClock(worldClock); + } + }); + + holder.container.setOnLongClickListener(new View.OnLongClickListener() { + @Override + public boolean onLongClick(View v) { + new AlertDialog.Builder(v.getContext()) + .setTitle(v.getContext().getString(R.string.world_clock_delete_confirm_title, worldClock.getLabel())) + .setMessage(R.string.world_clock_delete_confirm_description) + .setIcon(R.drawable.ic_warning) + .setPositiveButton(android.R.string.yes, new DialogInterface.OnClickListener() { + public void onClick(final DialogInterface dialog, final int whichButton) { + ((ConfigureWorldClocks) mContext).deleteWorldClock(worldClock); + } + }) + .setNegativeButton(android.R.string.no, null) + .show(); + + return true; + } + }); + + holder.worldClockLabel.setText(worldClock.getLabel()); + holder.worldClockTimezone.setText(worldClock.getTimeZoneId()); + + final DateFormat df = new SimpleDateFormat("HH:mm", GBApplication.getLanguage()); + df.setTimeZone(TimeZone.getTimeZone(worldClock.getTimeZoneId())); + holder.worldClockCurrentTime.setText(df.format(new Date())); + } + + @Override + public int getItemCount() { + return worldClockList.size(); + } + + static class ViewHolder extends RecyclerView.ViewHolder { + final CardView container; + + final TextView worldClockTimezone; + final TextView worldClockLabel; + final TextView worldClockCurrentTime; + + ViewHolder(View view) { + super(view); + + container = view.findViewById(R.id.card_view); + + worldClockTimezone = view.findViewById(R.id.world_clock_item_timezone); + worldClockLabel = view.findViewById(R.id.world_clock_item_label); + worldClockCurrentTime = view.findViewById(R.id.world_clock_current_time); + } + } +} diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/database/DBHelper.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/database/DBHelper.java index 7e97e9862..ef5981eae 100644 --- a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/database/DBHelper.java +++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/database/DBHelper.java @@ -62,6 +62,8 @@ import nodomain.freeyourgadget.gadgetbridge.entities.TagDao; import nodomain.freeyourgadget.gadgetbridge.entities.User; import nodomain.freeyourgadget.gadgetbridge.entities.UserAttributes; import nodomain.freeyourgadget.gadgetbridge.entities.UserDao; +import nodomain.freeyourgadget.gadgetbridge.entities.WorldClock; +import nodomain.freeyourgadget.gadgetbridge.entities.WorldClockDao; import nodomain.freeyourgadget.gadgetbridge.impl.GBDevice; import nodomain.freeyourgadget.gadgetbridge.model.ActivityUser; import nodomain.freeyourgadget.gadgetbridge.model.ValidByDate; @@ -658,6 +660,28 @@ public class DBHelper { return Collections.emptyList(); } + @NonNull + public static List getWorldClocks(@NonNull GBDevice gbDevice) { + try (DBHandler db = GBApplication.acquireDB()) { + final DaoSession daoSession = db.getDaoSession(); + final User user = getUser(daoSession); + final Device dbDevice = DBHelper.findDevice(gbDevice, daoSession); + if (dbDevice != null) { + final WorldClockDao worldClockDao = daoSession.getWorldClockDao(); + final Long deviceId = dbDevice.getId(); + final QueryBuilder qb = worldClockDao.queryBuilder(); + qb.where( + WorldClockDao.Properties.UserId.eq(user.getId()), + WorldClockDao.Properties.DeviceId.eq(deviceId)).orderAsc(WorldClockDao.Properties.WorldClockId); + return qb.build().list(); + } + } catch (final Exception e) { + LOG.error("Error reading world clocks from db", e); + } + + return Collections.emptyList(); + } + public static void store(final Reminder reminder) { try (DBHandler db = GBApplication.acquireDB()) { final DaoSession daoSession = db.getDaoSession(); @@ -667,6 +691,15 @@ public class DBHelper { } } + public static void store(final WorldClock worldClock) { + try (DBHandler db = GBApplication.acquireDB()) { + final DaoSession daoSession = db.getDaoSession(); + daoSession.insertOrReplace(worldClock); + } catch (final Exception e) { + LOG.error("Error acquiring database", e); + } + } + public static void delete(final Reminder reminder) { try (DBHandler db = GBApplication.acquireDB()) { final DaoSession daoSession = db.getDaoSession(); @@ -676,6 +709,15 @@ public class DBHelper { } } + public static void delete(final WorldClock worldClock) { + try (DBHandler db = GBApplication.acquireDB()) { + final DaoSession daoSession = db.getDaoSession(); + daoSession.delete(worldClock); + } catch (final Exception e) { + LOG.error("Error acquiring database", e); + } + } + public static void clearSession() { try (DBHandler dbHandler = GBApplication.acquireDB()) { DaoSession session = dbHandler.getDaoSession(); diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/AbstractDeviceCoordinator.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/AbstractDeviceCoordinator.java index 232c69628..f97a9608a 100644 --- a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/AbstractDeviceCoordinator.java +++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/AbstractDeviceCoordinator.java @@ -232,6 +232,16 @@ public abstract class AbstractDeviceCoordinator implements DeviceCoordinator { return 0; } + @Override + public int getWorldClocksSlotCount() { + return 0; + } + + @Override + public int getWorldClocksLabelLength() { + return 10; + } + @Override public boolean supportsRgbLedColor() { return false; diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/DeviceCoordinator.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/DeviceCoordinator.java index b329c7970..f5dc931bc 100644 --- a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/DeviceCoordinator.java +++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/DeviceCoordinator.java @@ -331,6 +331,16 @@ public interface DeviceCoordinator { */ int getReminderSlotCount(); + /** + * Indicates the maximum number of slots available for world clocks in the device. + */ + int getWorldClocksSlotCount(); + + /** + * Indicates the maximum label length for a world clock in the device. + */ + int getWorldClocksLabelLength(); + /** * Indicates whether the device has an led which supports custom colors */ diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/EventHandler.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/EventHandler.java index d20ab54e3..d4ccc7c67 100644 --- a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/EventHandler.java +++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/EventHandler.java @@ -32,6 +32,7 @@ import nodomain.freeyourgadget.gadgetbridge.model.MusicStateSpec; import nodomain.freeyourgadget.gadgetbridge.model.NotificationSpec; import nodomain.freeyourgadget.gadgetbridge.model.Reminder; import nodomain.freeyourgadget.gadgetbridge.model.WeatherSpec; +import nodomain.freeyourgadget.gadgetbridge.model.WorldClock; /** * Specifies all events that Gadgetbridge intends to send to the gadget device. @@ -49,6 +50,8 @@ public interface EventHandler { void onSetReminders(ArrayList reminders); + void onSetWorldClocks(ArrayList clocks); + void onSetCallState(CallSpec callSpec); void onSetCannedMessages(CannedMessagesSpec cannedMessagesSpec); diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huami/miband5/MiBand5Coordinator.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huami/miband5/MiBand5Coordinator.java index 75b2061ee..8bf08ac85 100644 --- a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huami/miband5/MiBand5Coordinator.java +++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huami/miband5/MiBand5Coordinator.java @@ -85,6 +85,16 @@ public class MiBand5Coordinator extends HuamiCoordinator { return true; } + @Override + public int getWorldClocksSlotCount() { + return 20; // as enforced by Mi Fit + } + + @Override + public int getWorldClocksLabelLength() { + return 30; // at least + } + @Override public int[] getSupportedDeviceSpecificSettings(GBDevice device) { return new int[]{ @@ -93,6 +103,7 @@ public class MiBand5Coordinator extends HuamiCoordinator { R.xml.devicesettings_custom_emoji_font, R.xml.devicesettings_timeformat, R.xml.devicesettings_dateformat, + R.xml.devicesettings_world_clocks, R.xml.devicesettings_nightmode, R.xml.devicesettings_liftwrist_display_sensitivity, R.xml.devicesettings_swipeunlock, diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/impl/GBDeviceService.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/impl/GBDeviceService.java index 69ede37da..54f57b170 100644 --- a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/impl/GBDeviceService.java +++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/impl/GBDeviceService.java @@ -43,6 +43,7 @@ import nodomain.freeyourgadget.gadgetbridge.model.MusicStateSpec; import nodomain.freeyourgadget.gadgetbridge.model.NotificationSpec; import nodomain.freeyourgadget.gadgetbridge.model.Reminder; import nodomain.freeyourgadget.gadgetbridge.model.WeatherSpec; +import nodomain.freeyourgadget.gadgetbridge.model.WorldClock; import nodomain.freeyourgadget.gadgetbridge.service.DeviceCommunicationService; import nodomain.freeyourgadget.gadgetbridge.util.RtlUtils; @@ -230,6 +231,13 @@ public class GBDeviceService implements DeviceService { invokeService(intent); } + @Override + public void onSetWorldClocks(ArrayList clocks) { + Intent intent = createIntent().setAction(ACTION_SET_WORLD_CLOCKS) + .putExtra(EXTRA_WORLD_CLOCKS, clocks); + invokeService(intent); + } + @Override public void onSetMusicInfo(MusicSpec musicSpec) { Intent intent = createIntent().setAction(ACTION_SETMUSICINFO) diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/model/DeviceService.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/model/DeviceService.java index 8304b8410..ddf52dc7f 100644 --- a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/model/DeviceService.java +++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/model/DeviceService.java @@ -56,6 +56,7 @@ public interface DeviceService extends EventHandler { String ACTION_SET_ALARMS = PREFIX + ".action.set_alarms"; String ACTION_SAVE_ALARMS = PREFIX + ".action.save_alarms"; String ACTION_SET_REMINDERS = PREFIX + ".action.set_reminders"; + String ACTION_SET_WORLD_CLOCKS = PREFIX + ".action.set_world_clocks"; String ACTION_ENABLE_REALTIME_STEPS = PREFIX + ".action.enable_realtime_steps"; String ACTION_REALTIME_SAMPLES = PREFIX + ".action.realtime_samples"; String ACTION_ENABLE_REALTIME_HEARTRATE_MEASUREMENT = PREFIX + ".action.realtime_hr_measurement"; @@ -110,6 +111,7 @@ public interface DeviceService extends EventHandler { String EXTRA_CONFIG = "config"; String EXTRA_ALARMS = "alarms"; String EXTRA_REMINDERS = "reminders"; + String EXTRA_WORLD_CLOCKS = "world_clocks"; String EXTRA_CONNECT_FIRST_TIME = "connect_first_time"; String EXTRA_BOOLEAN_ENABLE = "enable_realtime_steps"; String EXTRA_INTERVAL_SECONDS = "interval_seconds"; diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/model/WorldClock.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/model/WorldClock.java new file mode 100644 index 000000000..1c6b0bd1d --- /dev/null +++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/model/WorldClock.java @@ -0,0 +1,30 @@ +/* Copyright (C) 2022 José Rebelo + + This file is part of Gadgetbridge. + + Gadgetbridge is free software: you can redistribute it and/or modify + it under the terms of the GNU Affero General Public License as published + by the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + Gadgetbridge is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU Affero General Public License for more details. + + You should have received a copy of the GNU Affero General Public License + along with this program. If not, see . */ +package nodomain.freeyourgadget.gadgetbridge.model; + +import java.io.Serializable; + +public interface WorldClock extends Serializable { + /** + * The {@link android.os.Bundle} name for transferring parceled world clocks. + */ + String EXTRA_WORLD_CLOCK = "world_clock"; + + String getWorldClockId(); + String getLabel(); + String getTimeZoneId(); +} diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/DeviceCommunicationService.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/DeviceCommunicationService.java index a9f7ddfa6..52df51f46 100644 --- a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/DeviceCommunicationService.java +++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/DeviceCommunicationService.java @@ -75,6 +75,7 @@ import nodomain.freeyourgadget.gadgetbridge.model.NotificationSpec; import nodomain.freeyourgadget.gadgetbridge.model.NotificationType; import nodomain.freeyourgadget.gadgetbridge.model.Reminder; import nodomain.freeyourgadget.gadgetbridge.model.WeatherSpec; +import nodomain.freeyourgadget.gadgetbridge.model.WorldClock; import nodomain.freeyourgadget.gadgetbridge.service.receivers.AutoConnectIntervalReceiver; import nodomain.freeyourgadget.gadgetbridge.service.receivers.GBAutoFetchReceiver; import nodomain.freeyourgadget.gadgetbridge.util.DeviceHelper; @@ -120,6 +121,7 @@ import static nodomain.freeyourgadget.gadgetbridge.model.DeviceService.ACTION_SE import static nodomain.freeyourgadget.gadgetbridge.model.DeviceService.ACTION_SET_HEARTRATE_MEASUREMENT_INTERVAL; import static nodomain.freeyourgadget.gadgetbridge.model.DeviceService.ACTION_SET_LED_COLOR; import static nodomain.freeyourgadget.gadgetbridge.model.DeviceService.ACTION_SET_REMINDERS; +import static nodomain.freeyourgadget.gadgetbridge.model.DeviceService.ACTION_SET_WORLD_CLOCKS; import static nodomain.freeyourgadget.gadgetbridge.model.DeviceService.ACTION_START; import static nodomain.freeyourgadget.gadgetbridge.model.DeviceService.ACTION_STARTAPP; import static nodomain.freeyourgadget.gadgetbridge.model.DeviceService.ACTION_TEST_NEW_FUNCTION; @@ -178,6 +180,7 @@ import static nodomain.freeyourgadget.gadgetbridge.model.DeviceService.EXTRA_RES import static nodomain.freeyourgadget.gadgetbridge.model.DeviceService.EXTRA_URI; import static nodomain.freeyourgadget.gadgetbridge.model.DeviceService.EXTRA_VIBRATION_INTENSITY; import static nodomain.freeyourgadget.gadgetbridge.model.DeviceService.EXTRA_WEATHER; +import static nodomain.freeyourgadget.gadgetbridge.model.DeviceService.EXTRA_WORLD_CLOCKS; public class DeviceCommunicationService extends Service implements SharedPreferences.OnSharedPreferenceChangeListener { private static final Logger LOG = LoggerFactory.getLogger(DeviceCommunicationService.class); @@ -586,6 +589,10 @@ public class DeviceCommunicationService extends Service implements SharedPrefere ArrayList reminders = (ArrayList) intent.getSerializableExtra(EXTRA_REMINDERS); mDeviceSupport.onSetReminders(reminders); break; + case ACTION_SET_WORLD_CLOCKS: + ArrayList clocks = (ArrayList) intent.getSerializableExtra(EXTRA_WORLD_CLOCKS); + mDeviceSupport.onSetWorldClocks(clocks); + break; case ACTION_ENABLE_REALTIME_STEPS: { boolean enable = intent.getBooleanExtra(EXTRA_BOOLEAN_ENABLE, false); mDeviceSupport.onEnableRealtimeSteps(enable); diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/ServiceDeviceSupport.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/ServiceDeviceSupport.java index 0cc8a6d06..bc946df64 100644 --- a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/ServiceDeviceSupport.java +++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/ServiceDeviceSupport.java @@ -39,6 +39,7 @@ import nodomain.freeyourgadget.gadgetbridge.model.MusicStateSpec; import nodomain.freeyourgadget.gadgetbridge.model.NotificationSpec; import nodomain.freeyourgadget.gadgetbridge.model.Reminder; import nodomain.freeyourgadget.gadgetbridge.model.WeatherSpec; +import nodomain.freeyourgadget.gadgetbridge.model.WorldClock; /** * Wraps another device support instance and supports busy-checking and throttling of events. @@ -318,6 +319,14 @@ public class ServiceDeviceSupport implements DeviceSupport { delegate.onSetReminders(reminders); } + @Override + public void onSetWorldClocks(ArrayList clocks) { + if (checkBusy("set world clocks")) { + return; + } + delegate.onSetWorldClocks(clocks); + } + @Override public void onEnableRealtimeSteps(boolean enable) { if (checkBusy("enable realtime steps: " + enable)) { diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/btle/AbstractBTLEDeviceSupport.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/btle/AbstractBTLEDeviceSupport.java index 73a9e1b95..9959cc958 100644 --- a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/btle/AbstractBTLEDeviceSupport.java +++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/btle/AbstractBTLEDeviceSupport.java @@ -38,6 +38,7 @@ import java.util.UUID; import nodomain.freeyourgadget.gadgetbridge.Logging; import nodomain.freeyourgadget.gadgetbridge.impl.GBDevice; import nodomain.freeyourgadget.gadgetbridge.model.Reminder; +import nodomain.freeyourgadget.gadgetbridge.model.WorldClock; import nodomain.freeyourgadget.gadgetbridge.service.AbstractDeviceSupport; import nodomain.freeyourgadget.gadgetbridge.service.btle.actions.CheckInitializedAction; import nodomain.freeyourgadget.gadgetbridge.service.btle.profiles.AbstractBleProfile; @@ -378,6 +379,11 @@ public abstract class AbstractBTLEDeviceSupport extends AbstractDeviceSupport im } + @Override + public void onSetWorldClocks(ArrayList clocks) { + + } + @Override public void onConnectionStateChange(BluetoothDevice device, int status, int newState) { diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/huami/HuamiSupport.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/huami/HuamiSupport.java index 665c34f5a..dbf67a768 100644 --- a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/huami/HuamiSupport.java +++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/huami/HuamiSupport.java @@ -37,10 +37,16 @@ import net.e175.klaus.solarpositioning.SPA; import org.apache.commons.lang3.ArrayUtils; import org.slf4j.Logger; import org.slf4j.LoggerFactory; +import org.threeten.bp.Instant; +import org.threeten.bp.ZoneId; +import org.threeten.bp.zone.ZoneOffsetTransition; +import org.threeten.bp.zone.ZoneRules; +import java.io.ByteArrayOutputStream; import java.io.IOException; import java.nio.ByteBuffer; import java.nio.ByteOrder; +import java.nio.charset.StandardCharsets; import java.util.ArrayList; import java.util.Arrays; import java.util.Calendar; @@ -52,6 +58,7 @@ import java.util.Locale; import java.util.Map; import java.util.Set; import java.util.SimpleTimeZone; +import java.util.TimeZone; import java.util.Timer; import java.util.TimerTask; import java.util.UUID; @@ -112,6 +119,7 @@ import nodomain.freeyourgadget.gadgetbridge.model.NotificationType; import nodomain.freeyourgadget.gadgetbridge.model.Reminder; import nodomain.freeyourgadget.gadgetbridge.model.Weather; import nodomain.freeyourgadget.gadgetbridge.model.WeatherSpec; +import nodomain.freeyourgadget.gadgetbridge.model.WorldClock; import nodomain.freeyourgadget.gadgetbridge.service.btle.AbstractBTLEDeviceSupport; import nodomain.freeyourgadget.gadgetbridge.service.btle.BLETypeConversions; import nodomain.freeyourgadget.gadgetbridge.service.btle.BtLEAction; @@ -925,6 +933,120 @@ public class HuamiSupport extends AbstractBTLEDeviceSupport { writeToChunked(builder, 2, buf.array()); } + @Override + public void onSetWorldClocks(ArrayList clocks) { + final TransactionBuilder builder; + try { + builder = performInitialized("onSetWorldClocks"); + } catch (final IOException e) { + LOG.error("Unable to send world clocks to device", e); + return; + } + + sendWorldClocks(builder, clocks); + + builder.queue(getQueue()); + } + + private void setWorldClocks(final TransactionBuilder builder) { + final List clocks = DBHelper.getWorldClocks(gbDevice); + sendWorldClocks(builder, clocks); + } + + private void sendWorldClocks(final TransactionBuilder builder, final List clocks) { + final DeviceCoordinator coordinator = DeviceHelper.getInstance().getCoordinator(gbDevice); + if (coordinator.getWorldClocksSlotCount() == 0) { + return; + } + + final ByteArrayOutputStream baos = new ByteArrayOutputStream(); + + try { + baos.write(0x03); + + if (clocks.size() != 0) { + int i = clocks.size(); + for (final WorldClock clock : clocks) { + baos.write(i--); + baos.write(encodeWorldClock(clock)); + } + } else { + baos.write(0); + } + } catch (final IOException e) { + LOG.error("Unable to send world clocks to device", e); + return; + } + + writeToChunked2021(builder, (short) 0x0008, getNextHandle(), baos.toByteArray(), false, false); + } + + public byte[] encodeWorldClock(final WorldClock clock) { + final DeviceCoordinator coordinator = DeviceHelper.getInstance().getCoordinator(gbDevice); + + try { + final ByteArrayOutputStream baos = new ByteArrayOutputStream(); + + final TimeZone timezone = TimeZone.getTimeZone(clock.getTimeZoneId()); + final ZoneId zoneId = ZoneId.of(clock.getTimeZoneId()); + + // Usually the 3-letter city code (eg. LIS for Lisbon), but doesn't seem to be used in the UI + baos.write(" ".getBytes(StandardCharsets.UTF_8)); + baos.write(0x00); + + // Some other string? Seems to be empty + baos.write(0x00); + + // The city name / label that shows up on the band + baos.write(StringUtils.truncate(clock.getLabel(), coordinator.getWorldClocksLabelLength()).getBytes(StandardCharsets.UTF_8)); + baos.write(0x00); + + // The raw offset from UTC, in number of 15-minute blocks + baos.write((int) (timezone.getRawOffset() / (1000L * 60L * 15L))); + + // Daylight savings + final boolean useDaylightTime = timezone.useDaylightTime(); + final boolean inDaylightTime = timezone.inDaylightTime(new Date()); + byte daylightByte = 0; + // The daylight savings offset, either currently (the previous transition) or future (the next transition), in minutes + byte daylightOffsetMinutes = 0; + + final ZoneRules zoneRules = zoneId.getRules(); + if (useDaylightTime) { + final ZoneOffsetTransition transition; + if (inDaylightTime) { + daylightByte = 0x01; + transition = zoneRules.previousTransition(Instant.now()); + } else { + daylightByte = 0x02; + transition = zoneRules.nextTransition(Instant.now()); + } + daylightOffsetMinutes = (byte) transition.getDuration().toMinutes(); + } + + baos.write(daylightByte); + baos.write(daylightOffsetMinutes); + + // The timestamp of the next daylight savings transition, if any + final ZoneOffsetTransition nextTransition = zoneRules.nextTransition(Instant.now()); + long nextTransitionTs = 0; + if (nextTransition != null) { + nextTransitionTs = nextTransition + .getDateTimeBefore() + .atZone(zoneId) + .toEpochSecond(); + } + + for (int i = 0; i < 4; i++) { + baos.write((byte) ((nextTransitionTs >> (i * 8)) & 0xff)); + } + + return baos.toByteArray(); + } catch (final IOException e) { + throw new RuntimeException("This should never happen", e); + } + } + @Override public void onNotification(NotificationSpec notificationSpec) { if (notificationSpec.type == NotificationType.GENERIC_ALARM_CLOCK) { @@ -3351,6 +3473,7 @@ public class HuamiSupport extends AbstractBTLEDeviceSupport { setExposeHRThridParty(builder); setHeartrateMeasurementInterval(builder, getHeartRateMeasurementInterval()); sendReminders(builder); + setWorldClocks(builder); requestAlarms(builder); } diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/serial/AbstractSerialDeviceSupport.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/serial/AbstractSerialDeviceSupport.java index 39da5fb9e..d847031d0 100644 --- a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/serial/AbstractSerialDeviceSupport.java +++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/serial/AbstractSerialDeviceSupport.java @@ -31,6 +31,7 @@ import nodomain.freeyourgadget.gadgetbridge.model.MusicStateSpec; import nodomain.freeyourgadget.gadgetbridge.model.NotificationSpec; import nodomain.freeyourgadget.gadgetbridge.model.Reminder; import nodomain.freeyourgadget.gadgetbridge.model.WeatherSpec; +import nodomain.freeyourgadget.gadgetbridge.model.WorldClock; import nodomain.freeyourgadget.gadgetbridge.service.AbstractDeviceSupport; /** @@ -276,4 +277,10 @@ public abstract class AbstractSerialDeviceSupport extends AbstractDeviceSupport byte[] bytes = gbDeviceProtocol.encodeReminders(reminders); sendToDevice(bytes); } + + @Override + public void onSetWorldClocks(ArrayList clocks) { + byte[] bytes = gbDeviceProtocol.encodeWorldClocks(clocks); + sendToDevice(bytes); + } } diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/serial/GBDeviceProtocol.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/serial/GBDeviceProtocol.java index a4408d053..888d64756 100644 --- a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/serial/GBDeviceProtocol.java +++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/serial/GBDeviceProtocol.java @@ -27,6 +27,7 @@ import nodomain.freeyourgadget.gadgetbridge.model.CannedMessagesSpec; import nodomain.freeyourgadget.gadgetbridge.model.NotificationSpec; import nodomain.freeyourgadget.gadgetbridge.model.Reminder; import nodomain.freeyourgadget.gadgetbridge.model.WeatherSpec; +import nodomain.freeyourgadget.gadgetbridge.model.WorldClock; public abstract class GBDeviceProtocol { @@ -151,6 +152,10 @@ public abstract class GBDeviceProtocol { return null; } + public byte[] encodeWorldClocks(ArrayList clocks) { + return null; + } + public byte[] encodeFmFrequency(float frequency) { return null; } diff --git a/app/src/main/res/layout/activity_configure_world_clocks.xml b/app/src/main/res/layout/activity_configure_world_clocks.xml new file mode 100644 index 000000000..727f8c4df --- /dev/null +++ b/app/src/main/res/layout/activity_configure_world_clocks.xml @@ -0,0 +1,27 @@ + + + + + + diff --git a/app/src/main/res/layout/activity_world_clock_details.xml b/app/src/main/res/layout/activity_world_clock_details.xml new file mode 100644 index 000000000..93bc99fd6 --- /dev/null +++ b/app/src/main/res/layout/activity_world_clock_details.xml @@ -0,0 +1,97 @@ + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/layout/device_itemv2.xml b/app/src/main/res/layout/device_itemv2.xml index 98ff82cad..ef17481b9 100644 --- a/app/src/main/res/layout/device_itemv2.xml +++ b/app/src/main/res/layout/device_itemv2.xml @@ -73,15 +73,15 @@ android:id="@+id/device_image" android:layout_width="48dp" android:layout_height="48dp" - android:layout_alignParentStart="true" android:layout_below="@id/device_item_infos_box" - android:contentDescription="@string/candidate_item_device_image" - android:clickable="true" - android:longClickable="true" - android:background="?android:attr/selectableItemBackground" - card_view:srcCompat="@drawable/ic_device_pebble" + android:layout_alignParentStart="true" android:layout_marginTop="2dp" - android:focusable="true" /> + android:background="?android:attr/selectableItemBackground" + android:clickable="true" + android:contentDescription="@string/candidate_item_device_image" + android:focusable="true" + android:longClickable="true" + card_view:srcCompat="@drawable/ic_device_pebble" /> + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 8ec87947b..a2422f918 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -486,6 +486,8 @@ Activity and Sleep Configure alarms Configure reminders + World Clocks + Configure clocks for other timezones Configure alarms Configure reminders Repeat @@ -506,8 +508,15 @@ Are you sure you want to delete the reminder? No free slots The device has no free slots for reminders (total slots: %1$s) + Delete \'%1$s\' + Are you sure you want to delete the world clock? + No free slots + The device has no free slots for world clocks (total slots: %1$s) + Time Zone + Label Alarm details Reminder details + World Clock details Sun Mon Tue diff --git a/app/src/main/res/xml/devicesettings_world_clocks.xml b/app/src/main/res/xml/devicesettings_world_clocks.xml new file mode 100644 index 000000000..46d224761 --- /dev/null +++ b/app/src/main/res/xml/devicesettings_world_clocks.xml @@ -0,0 +1,8 @@ + + + +