1
0
mirror of https://codeberg.org/Freeyourgadget/Gadgetbridge synced 2024-11-27 20:36:51 +01:00

Fossil Hybrid HR: Add watchface designer

This commit is contained in:
Arjan Schrijver 2021-06-20 14:17:18 +02:00 committed by Gitea
parent bcc5afb78c
commit de403cf92e
31 changed files with 1153 additions and 116 deletions

View File

@ -618,6 +618,10 @@
android:name=".devices.qhybrid.CommuteActionsActivity"
android:label="@string/qhybrid_pref_title_actions"
android:parentActivityName=".devices.qhybrid.HRConfigActivity" />
<activity
android:name=".devices.qhybrid.HybridHRWatchfaceDesignerActivity"
android:label="Watchface designer"
android:parentActivityName=".activities.appmanager.AppManagerActivity" />
<activity
android:name=".devices.um25.Activity.DataActivity"
android:exported="true" />

View File

@ -0,0 +1,7 @@
          
              
 
            
 
              
           # <0C><>

View File

@ -0,0 +1,7 @@
%       
 
   
   
                                                   

   7 <0C><>

View File

@ -0,0 +1 @@
h                                              b <0C><>

View File

@ -0,0 +1,2 @@
&                                            
        2 <0C><>

View File

@ -0,0 +1,2 @@
#                                       
      2 <0C><>

View File

@ -0,0 +1 @@
8                                                                         < <0C><>

View File

@ -0,0 +1 @@
                                                                 <0C><>

View File

@ -0,0 +1,2 @@
9       
      5       3 <0C><>

View File

@ -0,0 +1,2 @@
8                  
      5       3 <0C><>

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@ -17,6 +17,7 @@
along with this program. If not, see <http://www.gnu.org/licenses/>. */
package nodomain.freeyourgadget.gadgetbridge.activities.appmanager;
import android.app.Activity;
import android.content.BroadcastReceiver;
import android.content.Context;
import android.content.Intent;
@ -249,8 +250,6 @@ public abstract class AbstractAppManagerFragment extends Fragment {
@Override
public void onActivityCreated(Bundle savedInstanceState) {
super.onActivityCreated(savedInstanceState);
mGBDevice = ((AppManagerActivity) getActivity()).getGBDevice();
mCoordinator = DeviceHelper.getInstance().getCoordinator(mGBDevice);
IntentFilter filter = new IntentFilter();
filter.addAction(ACTION_REFRESH_APPLIST);
@ -269,8 +268,12 @@ public abstract class AbstractAppManagerFragment extends Fragment {
@Override
public View onCreateView(LayoutInflater inflater, ViewGroup container, Bundle savedInstanceState) {
mGBDevice = ((AppManagerActivity) getActivity()).getGBDevice();
mCoordinator = DeviceHelper.getInstance().getCoordinator(mGBDevice);
final FloatingActionButton appListFab = ((FloatingActionButton) getActivity().findViewById(R.id.fab));
final FloatingActionButton appListFabNew = ((FloatingActionButton) getActivity().findViewById(R.id.fab_new));
final Class<? extends Activity> watchfaceDesignerActivity = mCoordinator.getWatchfaceDesignerActivity();
View rootView = inflater.inflate(R.layout.activity_appmanager, container, false);
RecyclerView appListView = (RecyclerView) (rootView.findViewById(R.id.appListView));
@ -280,8 +283,12 @@ public abstract class AbstractAppManagerFragment extends Fragment {
public void onScrolled(RecyclerView recyclerView, int dx, int dy) {
if (dy > 0) {
appListFab.hide();
appListFabNew.hide();
} else if (dy < 0) {
appListFab.show();
if (watchfaceDesignerActivity != null) {
appListFabNew.show();
}
}
}
});
@ -293,6 +300,19 @@ public abstract class AbstractAppManagerFragment extends Fragment {
appManagementTouchHelper = new ItemTouchHelper(appItemTouchHelperCallback);
appManagementTouchHelper.attachToRecyclerView(appListView);
if ((watchfaceDesignerActivity != null) && (appListFabNew != null)) {
appListFabNew.setOnClickListener(new View.OnClickListener() {
@Override
public void onClick(View v) {
Intent startIntent = new Intent(getContext(), watchfaceDesignerActivity);
startIntent.putExtra(GBDevice.EXTRA_DEVICE, mGBDevice);
getContext().startActivity(startIntent);
}
});
appListFabNew.show();
}
return rootView;
}
@ -385,7 +405,7 @@ public abstract class AbstractAppManagerFragment extends Fragment {
switch (item.getItemId()) {
case R.id.appmanager_app_delete_cache:
String baseName = selectedApp.getUUID().toString();
String[] suffixToDelete = new String[]{mCoordinator.getAppFileExtension(), ".json", "_config.js", "_preset.json"};
String[] suffixToDelete = new String[]{mCoordinator.getAppFileExtension(), ".json", "_config.js", "_preset.json", ".png"};
for (String suffix : suffixToDelete) {
File fileToDelete = new File(appCacheDir,baseName + suffix);
if (!fileToDelete.delete()) {

View File

@ -51,7 +51,7 @@ import nodomain.freeyourgadget.gadgetbridge.util.FileUtils;
public class AppManagerActivity extends AbstractGBFragmentActivity {
private static final Logger LOG = LoggerFactory.getLogger(AbstractAppManagerFragment.class);
private static final Logger LOG = LoggerFactory.getLogger(AppManagerActivity.class);
private int READ_REQUEST_CODE = 42;
private GBDevice mGBDevice = null;

View File

@ -174,6 +174,12 @@ public abstract class AbstractDeviceCoordinator implements DeviceCoordinator {
return false;
}
@Nullable
@Override
public Class<? extends Activity> getWatchfaceDesignerActivity() {
return null;
}
@Override
public int getBondingStyle() {
return BONDING_STYLE_ASK;

View File

@ -244,6 +244,13 @@ public interface DeviceCoordinator {
*/
Class<? extends Activity> getAppsManagementActivity();
/**
* Returns the Activity class that will be used to design watchfaces.
*
* @return
*/
Class<? extends Activity> getWatchfaceDesignerActivity();
/**
* Returns the device app cache directory.
*/

View File

@ -0,0 +1,161 @@
/* Copyright (C) 2021 Arjan Schrijver, Daniel Dakhno
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 <http://www.gnu.org/licenses/>. */
package nodomain.freeyourgadget.gadgetbridge.devices.qhybrid;
import android.content.Context;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.io.InputStream;
import java.nio.charset.StandardCharsets;
import java.util.LinkedHashMap;
import nodomain.freeyourgadget.gadgetbridge.util.CRC32C;
import nodomain.freeyourgadget.gadgetbridge.util.StringUtils;
/**
* Writes watch apps to a file in the Fossil Hybrid HR .wapp format.
*/
public class FossilAppWriter {
private final Logger LOG = LoggerFactory.getLogger(FossilAppWriter.class);
private Context mContext;
private String version;
private LinkedHashMap<String, InputStream> code;
private LinkedHashMap<String, InputStream> icons;
private LinkedHashMap<String, String> layout;
private LinkedHashMap<String, String> displayName;
private LinkedHashMap<String, String> config;
public FossilAppWriter(Context context, String version, LinkedHashMap<String, InputStream> code, LinkedHashMap<String, InputStream> icons, LinkedHashMap<String, String> layout, LinkedHashMap<String, String> displayName, LinkedHashMap<String, String> config) {
this.mContext = context;
if (this.mContext == null) throw new AssertionError("context cannot be null");
this.version = version;
if (!this.version.matches("^[0-9]\\.[0-9]\\.[0-9]\\.[0-9]$")) throw new AssertionError("Version must be in x.x.x.x format");
this.code = code;
if (this.code.size() == 0) throw new AssertionError("At least one code file InputStream must be supplied");
this.icons = icons;
if (this.icons == null) throw new AssertionError("icons cannot be null");
this.layout = layout;
if (this.layout == null) throw new AssertionError("layout cannot be null");
this.displayName = displayName;
if (this.displayName == null) throw new AssertionError("displayName cannot be null");
this.config = config;
if (this.config == null) throw new AssertionError("config cannot be null");
}
public byte[] getWapp() throws IOException {
byte[] codeData = loadFiles(code);
byte[] iconsData = loadFiles(icons);
byte[] layoutData = loadStringFiles(layout);
byte[] displayNameData = loadStringFiles(displayName);
byte[] configData = loadStringFiles(config);
int offsetCode = 88;
int offsetIcons = offsetCode + codeData.length;
int offsetLayout = offsetIcons + iconsData.length;
int offsetDisplayName = offsetLayout + layoutData.length;
int offsetConfig = offsetDisplayName + displayNameData.length;
int offsetFileEnd = offsetConfig + configData.length;
ByteArrayOutputStream filePart = new ByteArrayOutputStream();
String[] versionParts = this.version.split("\\.");
for (String versionPart : versionParts) {
filePart.write(Integer.valueOf(versionPart).byteValue());
}
filePart.write(intToLEBytes(0));
filePart.write(intToLEBytes(0));
filePart.write(intToLEBytes(offsetCode));
filePart.write(intToLEBytes(offsetIcons));
filePart.write(intToLEBytes(offsetLayout));
filePart.write(intToLEBytes(offsetDisplayName));
filePart.write(intToLEBytes(offsetDisplayName));
filePart.write(intToLEBytes(offsetConfig));
filePart.write(intToLEBytes(offsetFileEnd));
filePart.write(intToLEBytes(0));
filePart.write(intToLEBytes(0));
filePart.write(intToLEBytes(0));
filePart.write(intToLEBytes(0));
filePart.write(intToLEBytes(0));
filePart.write(intToLEBytes(0));
filePart.write(intToLEBytes(0));
filePart.write(intToLEBytes(0));
filePart.write(intToLEBytes(0));
filePart.write(codeData);
filePart.write(iconsData);
filePart.write(layoutData);
filePart.write(displayNameData);
filePart.write(configData);
byte[] filePartBytes = filePart.toByteArray();
ByteArrayOutputStream wapp = new ByteArrayOutputStream();
wapp.write(new byte[]{(byte)0xFE, (byte)0x15}); // file handle
wapp.write(new byte[]{(byte)0x03, (byte)0x00}); // file version
wapp.write(intToLEBytes(0)); // file offset
wapp.write(intToLEBytes(filePartBytes.length));
wapp.write(filePartBytes);
CRC32C crc = new CRC32C();
crc.update(filePartBytes,0,filePartBytes.length);
wapp.write(intToLEBytes((int)crc.getValue()));
return wapp.toByteArray();
}
public byte[] loadFiles(LinkedHashMap<String, InputStream> filesMap) throws IOException {
ByteArrayOutputStream output = new ByteArrayOutputStream();
for (String filename : filesMap.keySet()) {
InputStream in = filesMap.get(filename);
output.write((byte)filename.length() + 1);
output.write(StringUtils.terminateNull(filename).getBytes(StandardCharsets.UTF_8));
output.write(shortToLEBytes((short)in.available()));
byte[] fileBytes = new byte[in.available()];
in.read(fileBytes);
output.write(fileBytes);
}
return output.toByteArray();
}
public byte[] loadStringFiles(LinkedHashMap<String, String> stringsMap) throws IOException {
ByteArrayOutputStream output = new ByteArrayOutputStream();
for (String filename : stringsMap.keySet()) {
output.write((byte)filename.length() + 1);
output.write(StringUtils.terminateNull(filename).getBytes(StandardCharsets.UTF_8));
output.write(shortToLEBytes((short)(stringsMap.get(filename).length() + 1)));
output.write(StringUtils.terminateNull(stringsMap.get(filename)).getBytes(StandardCharsets.UTF_8));
}
return output.toByteArray();
}
private static byte[] intToLEBytes(int number) {
byte[] b = new byte[4];
b[0] = (byte) (number & 0xFF);
b[1] = (byte) ((number >> 8) & 0xFF);
b[2] = (byte) ((number >> 16) & 0xFF);
b[3] = (byte) ((number >> 24) & 0xFF);
return b;
}
private static byte[] shortToLEBytes(short number) {
byte[] b = new byte[2];
b[0] = (byte) (number & 0xFF);
b[1] = (byte) ((number >> 8) & 0xFF);
return b;
}
}

View File

@ -43,6 +43,7 @@ import nodomain.freeyourgadget.gadgetbridge.util.UriHelper;
*/
public class FossilFileReader {
private static final Logger LOG = LoggerFactory.getLogger(FossilFileReader.class);
private Uri uri;
private final UriHelper uriHelper;
private boolean isValid = false;
private boolean isFirmware = false;
@ -54,6 +55,7 @@ public class FossilFileReader {
private JSONObject mAppKeys;
public FossilFileReader(Uri uri, Context context) throws IOException {
this.uri = uri;
uriHelper = UriHelper.get(uri, context);
try (InputStream in = new BufferedInputStream(uriHelper.openInputStream())) {
@ -190,6 +192,10 @@ public class FossilFileReader {
return isWatchface;
}
public Uri getUri() {
return uri;
}
public String getVersion() {
return foundVersion;
}

View File

@ -18,6 +18,7 @@ package nodomain.freeyourgadget.gadgetbridge.devices.qhybrid;
import android.content.Context;
import android.content.Intent;
import android.graphics.Bitmap;
import android.net.Uri;
import androidx.localbroadcastmanager.content.LocalBroadcastManager;
@ -29,6 +30,7 @@ import org.slf4j.LoggerFactory;
import java.io.BufferedWriter;
import java.io.File;
import java.io.FileOutputStream;
import java.io.FileWriter;
import java.io.IOException;
import java.io.Writer;
@ -104,6 +106,12 @@ public class FossilHRInstallHandler implements InstallHandler {
if (fossilFile.isFirmware()) {
return;
}
saveAppInCache(fossilFile, null, mCoordinator, mContext);
// refresh list
manager.sendBroadcast(new Intent(AbstractAppManagerFragment.ACTION_REFRESH_APPLIST));
}
public static void saveAppInCache(FossilFileReader fossilFile, Bitmap backgroundImg, DeviceCoordinator mCoordinator, Context mContext) {
GBDeviceApp app;
File destDir;
// write app file
@ -111,7 +119,7 @@ public class FossilHRInstallHandler implements InstallHandler {
app = fossilFile.getGBDeviceApp();
destDir = mCoordinator.getAppCacheDir();
destDir.mkdirs();
FileUtils.copyURItoFile(mContext, mUri, new File(destDir, app.getUUID().toString() + mCoordinator.getAppFileExtension()));
FileUtils.copyURItoFile(mContext, fossilFile.getUri(), new File(destDir, app.getUUID().toString() + mCoordinator.getAppFileExtension()));
} catch (IOException e) {
LOG.error("Saving app in cache failed: " + e.getMessage(), e);
return;
@ -140,8 +148,17 @@ public class FossilHRInstallHandler implements InstallHandler {
} catch (JSONException e) {
LOG.error(e.getMessage(), e);
}
// refresh list
manager.sendBroadcast(new Intent(AbstractAppManagerFragment.ACTION_REFRESH_APPLIST));
// write watchface background image
if (backgroundImg != null) {
outputFile = new File(destDir, app.getUUID().toString() + ".png");
try {
FileOutputStream fos = new FileOutputStream(outputFile);
backgroundImg.compress(Bitmap.CompressFormat.PNG, 9, fos);
fos.close();
} catch (IOException e) {
LOG.error("Failed to write to output file: " + e.getMessage(), e);
}
}
}
@Override

View File

@ -0,0 +1,375 @@
/* Copyright (C) 2021 Arjan Schrijver
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 <http://www.gnu.org/licenses/>. */
package nodomain.freeyourgadget.gadgetbridge.devices.qhybrid;
import androidx.appcompat.app.AppCompatActivity;
import android.app.Activity;
import android.app.AlertDialog;
import android.content.ContentResolver;
import android.content.DialogInterface;
import android.content.Intent;
import android.database.Cursor;
import android.graphics.Bitmap;
import android.graphics.Canvas;
import android.graphics.Color;
import android.graphics.Matrix;
import android.graphics.Paint;
import android.net.Uri;
import android.os.Bundle;
import android.os.Handler;
import android.provider.MediaStore;
import android.util.DisplayMetrics;
import android.view.View;
import android.widget.EditText;
import android.widget.ImageView;
import android.widget.LinearLayout;
import android.widget.NumberPicker;
import android.widget.RadioButton;
import android.widget.RadioGroup;
import android.widget.RelativeLayout;
import android.widget.TextView;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.io.BufferedOutputStream;
import java.io.File;
import java.io.FileOutputStream;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.util.ArrayList;
import java.util.UUID;
import nodomain.freeyourgadget.gadgetbridge.GBApplication;
import nodomain.freeyourgadget.gadgetbridge.R;
import nodomain.freeyourgadget.gadgetbridge.devices.DeviceCoordinator;
import nodomain.freeyourgadget.gadgetbridge.impl.GBDevice;
import nodomain.freeyourgadget.gadgetbridge.util.BitmapUtil;
import nodomain.freeyourgadget.gadgetbridge.util.DeviceHelper;
public class HybridHRWatchfaceDesignerActivity extends AppCompatActivity implements View.OnClickListener {
private final Logger LOG = LoggerFactory.getLogger(HybridHRWatchfaceDesignerActivity.class);
private GBDevice mGBDevice;
private DeviceCoordinator mCoordinator;
private int displayImageSize = 0;
private float scaleFactor = 0;
private ImageView backgroundImageView;
private Bitmap selectedBackgroundImage, processedBackgroundImage;
private String watchfaceName = "NewWatchface";
final private ArrayList<HybridHRWatchfaceWidget> widgets = new ArrayList<>();
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setContentView(R.layout.activity_hybridhr_watchface_designer);
Intent intent = getIntent();
Bundle bundle = intent.getExtras();
if (bundle != null) {
mGBDevice = bundle.getParcelable(GBDevice.EXTRA_DEVICE);
} else {
throw new IllegalArgumentException("Must provide a device when invoking this activity");
}
mCoordinator = DeviceHelper.getInstance().getCoordinator(mGBDevice);
calculateDisplayImageSize();
backgroundImageView = findViewById(R.id.hybridhr_background_image);
renderWatchfacePreview();
findViewById(R.id.button_edit_name).setOnClickListener(this);
findViewById(R.id.button_set_background).setOnClickListener(this);
findViewById(R.id.button_add_widget).setOnClickListener(this);
findViewById(R.id.button_watchface_settings).setOnClickListener(this);
findViewById(R.id.button_preview_watchface).setOnClickListener(this);
findViewById(R.id.button_save_watchface).setOnClickListener(this);
}
@Override
public void onActivityResult(int requestCode, int resultCode, Intent resultData) {
super.onActivityResult(requestCode, resultCode, resultData);
if (requestCode == 42 && resultCode == Activity.RESULT_OK) {
Uri imageUri = resultData.getData();
if (imageUri == null) {
LOG.warn("No image selected");
return;
}
try {
selectedBackgroundImage = createImageFromURI(imageUri);
} catch (IOException e) {
LOG.warn("Error converting selected image to Bitmap", e);
return;
}
renderWatchfacePreview();
}
}
@Override
public void onClick(View v) {
if (v.getId() == R.id.button_edit_name) {
final EditText input = new EditText(this);
input.setText(watchfaceName);
input.setId(0);
LinearLayout.LayoutParams lp = new LinearLayout.LayoutParams(
LinearLayout.LayoutParams.MATCH_PARENT,
LinearLayout.LayoutParams.MATCH_PARENT);
input.setLayoutParams(lp);
new AlertDialog.Builder(this)
.setView(input)
.setNegativeButton(R.string.fossil_hr_new_action_cancel, null)
.setPositiveButton(R.string.ok, new DialogInterface.OnClickListener() {
@Override
public void onClick(DialogInterface dialog, int which) {
watchfaceName = input.getText().toString();
TextView watchfaceNameView = findViewById(R.id.watchface_name);
watchfaceNameView.setText(watchfaceName);
}
})
.setTitle("Set watchface name")
.show();
} else if (v.getId() == R.id.button_set_background) {
Intent intent = new Intent(Intent.ACTION_OPEN_DOCUMENT);
intent.addCategory(Intent.CATEGORY_OPENABLE);
intent.setType("image/*");
startActivityForResult(intent, 42);
} else if (v.getId() == R.id.button_add_widget) {
showWidgetEditPopup(-1);
} else if (v.getId() == R.id.button_preview_watchface) {
sendToWatch(true);
} else if (v.getId() == R.id.button_save_watchface) {
sendToWatch(false);
}
}
private void renderWatchfacePreview() {
int widgetSize = 50;
if (selectedBackgroundImage == null) {
processedBackgroundImage = Bitmap.createBitmap(displayImageSize, displayImageSize, Bitmap.Config.ARGB_8888);
// Paint a gray circle around the watchface
Canvas backgroundImageCanvas = new Canvas(processedBackgroundImage);
Paint circlePaint = new Paint();
circlePaint.setColor(Color.GRAY);
circlePaint.setAntiAlias(true);
circlePaint.setStrokeWidth(3);
circlePaint.setStyle(Paint.Style.STROKE);
backgroundImageCanvas.drawCircle(displayImageSize/2f + 2, displayImageSize/2f + 2, displayImageSize/2f - 5, circlePaint);
} else {
processedBackgroundImage = Bitmap.createScaledBitmap(selectedBackgroundImage, displayImageSize, displayImageSize, false);
}
// Remove existing widget ImageViews
RelativeLayout imageContainer = this.findViewById(R.id.watchface_preview_image);
boolean onlyPreviewIsRemaining = false;
while (!onlyPreviewIsRemaining) {
int childCount = imageContainer.getChildCount();
int i;
for(i=0; i<childCount; i++) {
View currentChild = imageContainer.getChildAt(i);
if (currentChild.getId() != R.id.hybridhr_background_image) {
imageContainer.removeView(currentChild);
break;
}
}
if (i == childCount) {
onlyPreviewIsRemaining = true;
}
}
// Dynamically add an ImageView for each widget
Paint widgetPaint = new Paint();
widgetPaint.setColor(Color.RED);
widgetPaint.setStyle(Paint.Style.STROKE);
widgetPaint.setStrokeWidth(5);
Bitmap widgetBitmap = Bitmap.createBitmap((int)(widgetSize * scaleFactor), (int)(widgetSize * scaleFactor), Bitmap.Config.ARGB_8888);
Canvas widgetCanvas = new Canvas(widgetBitmap);
widgetCanvas.drawRect(0, 0, widgetSize * scaleFactor, widgetSize * scaleFactor, widgetPaint);
for (int i=0; i<widgets.size(); i++) {
HybridHRWatchfaceWidget widget = widgets.get(i);
RelativeLayout.LayoutParams layoutParams = new RelativeLayout.LayoutParams(RelativeLayout.LayoutParams.WRAP_CONTENT, RelativeLayout.LayoutParams.WRAP_CONTENT);
layoutParams.addRule(RelativeLayout.ALIGN_LEFT, backgroundImageView.getId());
layoutParams.addRule(RelativeLayout.ALIGN_TOP, backgroundImageView.getId());
layoutParams.setMargins((int) ((widget.getPosX() - widgetSize/2) * scaleFactor), (int) ((widget.getPosY() - widgetSize/2) * scaleFactor), 0, 0);
ImageView widgetView = new ImageView(this);
widgetView.setId(i);
widgetView.setImageBitmap(widgetBitmap);
widgetView.setLayoutParams(layoutParams);
widgetView.setOnClickListener(new View.OnClickListener() {
@Override
public void onClick(View v) {
showWidgetEditPopup(v.getId());
}
});
imageContainer.addView(widgetView);
}
backgroundImageView.setImageBitmap(processedBackgroundImage);
}
private void showWidgetEditPopup(final int index) {
LinearLayout layout = new LinearLayout(this);
HybridHRWatchfaceWidget widget = null;
if (index >= 0) {
widget = widgets.get(index);
}
layout.setOrientation(LinearLayout.VERTICAL);
TextView desc = new TextView(this);
desc.setText("Type:");
layout.addView(desc);
final RadioGroup typeSelector = new RadioGroup(this);
RadioButton typeDate = new RadioButton(this);
typeDate.setText("Date");
typeDate.setId(0);
if ((widget != null) && (widget.getWidgetType().equals("widgetDate"))) {
typeDate.setChecked(true);
}
typeSelector.addView(typeDate);
RadioButton typeWeather = new RadioButton(this);
typeWeather.setText("Weather");
typeWeather.setId(0+1);
if ((widget != null) && (widget.getWidgetType().equals("widgetWeather"))) {
typeWeather.setChecked(true);
}
typeSelector.addView(typeWeather);
LinearLayout.LayoutParams lp = new LinearLayout.LayoutParams(
LinearLayout.LayoutParams.MATCH_PARENT,
LinearLayout.LayoutParams.MATCH_PARENT);
typeSelector.setLayoutParams(lp);
layout.addView(typeSelector);
desc = new TextView(this);
desc.setText("X coordinate:");
layout.addView(desc);
final NumberPicker posX = new NumberPicker(this);
posX.setMinValue(1);
posX.setMaxValue(240);
if (widget != null) {
posX.setValue(widget.getPosX());
}
layout.addView(posX);
desc = new TextView(this);
desc.setText("Y coordinate:");
layout.addView(desc);
final NumberPicker posY = new NumberPicker(this);
posY.setMinValue(1);
posY.setMaxValue(240);
if (widget != null) {
posY.setValue(widget.getPosY());
}
layout.addView(posY);
new AlertDialog.Builder(this)
.setView(layout)
.setNegativeButton(R.string.fossil_hr_edit_action_delete, new DialogInterface.OnClickListener() {
@Override
public void onClick(DialogInterface dialog, int which) {
if (index >= 0) {
widgets.remove(index);
renderWatchfacePreview();
}
}
})
.setPositiveButton(R.string.ok, new DialogInterface.OnClickListener() {
@Override
public void onClick(DialogInterface dialog, int which) {
int selectedRadioId = typeSelector.getCheckedRadioButtonId();
int selectedPosX = posX.getValue();
int selectedPosY = posY.getValue();
switch (selectedRadioId) {
case 0:
if (index >= 0) {
widgets.set(index, new HybridHRWatchfaceWidget("widgetDate", selectedPosX, selectedPosY));
} else {
widgets.add(new HybridHRWatchfaceWidget("widgetDate", selectedPosX, selectedPosY));
}
break;
case 1:
if (index >= 0) {
widgets.set(index, new HybridHRWatchfaceWidget("widgetWeather", selectedPosX, selectedPosY));
} else {
widgets.add(new HybridHRWatchfaceWidget("widgetWeather", selectedPosX, selectedPosY));
}
break;
}
renderWatchfacePreview();
}
})
.setTitle("Add widget")
.show();
}
private void calculateDisplayImageSize() {
DisplayMetrics displayMetrics = new DisplayMetrics();
getWindowManager().getDefaultDisplay().getMetrics(displayMetrics);
displayImageSize = (int) Math.round(displayMetrics.widthPixels * 0.75);
scaleFactor = displayImageSize / 240f;
}
private Bitmap createImageFromURI(Uri imageUri) throws IOException, RuntimeException {
if (imageUri == null) {
throw new RuntimeException("No image selected");
}
// UriHelper uriHelper = UriHelper.get(imageUri, this);
// InputStream in = new BufferedInputStream(uriHelper.openInputStream());
// Bitmap bitmap = BitmapFactory.decodeStream(in);
ContentResolver resolver = getContentResolver();
Cursor c = resolver.query(imageUri, new String[]{MediaStore.Images.ImageColumns.ORIENTATION}, null, null, null);
c.moveToFirst();
int orientation = c.getInt(c.getColumnIndex(MediaStore.Images.ImageColumns.ORIENTATION));
c.close();
Bitmap bitmap = MediaStore.Images.Media.getBitmap(this.getContentResolver(), imageUri);
if (orientation != 0) { // FIXME: doesn't seem to work
Matrix matrix = new Matrix();
matrix.postRotate(90);
bitmap = Bitmap.createBitmap(bitmap, 0, 0, bitmap.getWidth(), bitmap.getHeight(), matrix, true);
}
return BitmapUtil.convertToGrayscale(BitmapUtil.getCircularBitmap(bitmap));
}
private void sendToWatch(boolean preview) {
HybridHRWatchfaceFactory wfFactory;
if (preview) {
wfFactory = new HybridHRWatchfaceFactory("previewWatchface");
} else {
wfFactory = new HybridHRWatchfaceFactory(watchfaceName);
}
wfFactory.setBackground(processedBackgroundImage);
wfFactory.addWidgets(widgets);
try {
File tempFile = File.createTempFile("tmpWatchfaceFile", null);
tempFile.deleteOnExit();
FileOutputStream fos = new FileOutputStream(tempFile);
BufferedOutputStream bos = new BufferedOutputStream(fos);
bos.write(wfFactory.getWapp(this));
bos.close();
fos.close();
Uri tempAppFileUri = Uri.fromFile(tempFile);
GBApplication.deviceService().onInstallApp(tempAppFileUri);
if (preview) {
new Handler().postDelayed(new Runnable() {
@Override
public void run() {
GBApplication.deviceService().onAppDelete(UUID.nameUUIDFromBytes("previewWatchface".getBytes(StandardCharsets.UTF_8)));
}
}, 10000);
} else {
FossilFileReader fossilFile = new FossilFileReader(tempAppFileUri, this);
FossilHRInstallHandler.saveAppInCache(fossilFile, processedBackgroundImage, mCoordinator, this);
}
} catch (IOException e) {
LOG.warn("Error while creating and uploading watchface", e);
}
}
}

View File

@ -0,0 +1,265 @@
/* Copyright (C) 2021 Arjan Schrijver
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 <http://www.gnu.org/licenses/>. */
package nodomain.freeyourgadget.gadgetbridge.devices.qhybrid;
import android.content.Context;
import android.graphics.Bitmap;
import org.json.JSONArray;
import org.json.JSONException;
import org.json.JSONObject;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.io.ByteArrayInputStream;
import java.io.IOException;
import java.io.InputStream;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import nodomain.freeyourgadget.gadgetbridge.service.devices.qhybrid.requests.fossil_hr.image.ImageConverter;
public class HybridHRWatchfaceFactory {
private final Logger LOG = LoggerFactory.getLogger(HybridHRWatchfaceFactory.class);
private String watchfaceName;
private Bitmap background;
private ArrayList<JSONObject> widgets = new ArrayList<>();
public HybridHRWatchfaceFactory(String name) {
watchfaceName = name.replaceAll("[^-A-Za-z0-9]", "");
if (watchfaceName.equals("")) throw new AssertionError("name cannot be empty");
if (watchfaceName.endsWith("App")) watchfaceName += "Watchface";
}
public void setBackground(Bitmap background) {
if ((background.getWidth() == 240) && (background.getHeight() == 240)) {
this.background = background;
} else {
this.background = Bitmap.createScaledBitmap(background, 240, 240, true);
}
}
public void addWidget(HybridHRWatchfaceWidget widgetDesc) {
JSONObject widget = new JSONObject();
try {
switch (widgetDesc.getWidgetType()) {
case "widgetDate":
widget.put("type", "comp");
widget.put("name", widgetDesc.getWidgetType());
widget.put("goal_ring", false);
widget.put("color", "white");
widget.put("bg", "_00.rle");
break;
case "widgetWeather":
widget.put("type", "comp");
widget.put("name", widgetDesc.getWidgetType());
widget.put("goal_ring", false);
widget.put("color", "white");
widget.put("bg", "_01.rle");
break;
default:
LOG.warn("Invalid widget name: " + widgetDesc.getWidgetType());
return;
}
JSONObject size = new JSONObject();
size.put("w", 76);
size.put("h", 76);
widget.put("size", size);
JSONObject pos = new JSONObject();
pos.put("x", widgetDesc.getPosX());
pos.put("y", widgetDesc.getPosY());
widget.put("pos", pos);
widgets.add(widget);
} catch (JSONException e) {
LOG.warn("JSON error", e);
}
}
public void addWidgets(ArrayList<HybridHRWatchfaceWidget> widgets) {
for (HybridHRWatchfaceWidget widget : widgets) {
addWidget(widget);
}
}
public byte[] getWapp(Context context) throws IOException {
byte[] backgroundBytes = ImageConverter.encodeToRawImage(ImageConverter.get2BitsRAWImageBytes(background));
InputStream backgroundStream = new ByteArrayInputStream(backgroundBytes);
LinkedHashMap<String, InputStream> code = new LinkedHashMap<>();
try {
code.put(watchfaceName, context.getAssets().open("fossil_hr/openSourceWatchface"));
code.put("widgetDate", context.getAssets().open("fossil_hr/widgetDate"));
code.put("widgetWeather", context.getAssets().open("fossil_hr/widgetWeather"));
} catch (IOException e) {
LOG.warn("Unable to read asset file", e);
}
LinkedHashMap<String, InputStream> icons = new LinkedHashMap<>();
try {
icons.put("background.raw", backgroundStream);
icons.put("icWthClearDay", context.getAssets().open("fossil_hr/icWthClearDay"));
icons.put("icWthClearNite", context.getAssets().open("fossil_hr/icWthClearNite"));
icons.put("icWthCloudy", context.getAssets().open("fossil_hr/icWthCloudy"));
icons.put("icWthPartCloudyDay", context.getAssets().open("fossil_hr/icWthPartCloudyDay"));
icons.put("icWthPartCloudyNite", context.getAssets().open("fossil_hr/icWthPartCloudyNite"));
icons.put("icWthRainy", context.getAssets().open("fossil_hr/icWthRainy"));
icons.put("icWthSnowy", context.getAssets().open("fossil_hr/icWthSnowy"));
icons.put("icWthStormy", context.getAssets().open("fossil_hr/icWthStormy"));
icons.put("icWthWindy", context.getAssets().open("fossil_hr/icWthWindy"));
} catch (IOException e) {
LOG.warn("Unable to read asset file", e);
}
LinkedHashMap<String, String> layout = new LinkedHashMap<>();
try {
layout.put("complication_layout", getComplicationLayout());
} catch (JSONException e) {
LOG.warn("Could not generate complication_layout", e);
}
try {
layout.put("image_layout", getImageLayout());
} catch (JSONException e) {
LOG.warn("Could not generate image_layout", e);
}
LinkedHashMap<String, String> displayName = new LinkedHashMap<>();
displayName.put("display_name", watchfaceName);
displayName.put("theme_class", "complications");
LinkedHashMap<String, String> config = new LinkedHashMap<>();
try {
config.put("customWatchFace", getConfiguration());
} catch (JSONException e) {
LOG.warn("Could not generate configuration", e);
}
FossilAppWriter appWriter = new FossilAppWriter(context, "1.2.0.0", code, icons, layout, displayName, config);
return appWriter.getWapp();
}
private String getComplicationLayout() throws JSONException {
JSONArray complicationLayout = new JSONArray();
JSONObject complicationBackground = new JSONObject();
complicationBackground.put("id", 0);
complicationBackground.put("type", "complication_background");
complicationBackground.put("background", "#background");
complicationBackground.put("visible", true);
complicationBackground.put("inversion", false);
JSONObject goalRing = new JSONObject();
goalRing.put("is_enable", "#goal_ring");
goalRing.put("end_angle", "#fi");
goalRing.put("is_invert", "#$e");
complicationBackground.put("goal_ring", goalRing);
JSONObject dimension = new JSONObject();
dimension.put("type", "rigid");
dimension.put("width", "#size.w");
dimension.put("height", "#size.h");
complicationBackground.put("dimension", dimension);
JSONObject placement = new JSONObject();
placement.put("type", "absolute");
placement.put("left", "#pos.Ue");
placement.put("top", "#pos.Qe");
complicationBackground.put("placement", placement);
complicationLayout.put(complicationBackground);
JSONObject complicationContent = new JSONObject();
complicationContent.put("id", 1);
complicationContent.put("parent_id", 0);
complicationContent.put("type", "complication_content");
complicationContent.put("icon", "#icon");
complicationContent.put("text_high", "#dt");
complicationContent.put("text_low", "#ci");
complicationContent.put("visible", true);
complicationContent.put("inversion", "#$e");
dimension = new JSONObject();
dimension.put("type", "rigid");
dimension.put("width", 76);
dimension.put("height", 76);
complicationContent.put("dimension", dimension);
placement = new JSONObject();
placement.put("type", "relative");
complicationContent.put("placement", placement);
complicationLayout.put(complicationContent);
return complicationLayout.toString();
}
private String getImageLayout() throws JSONException {
JSONArray imageLayout = new JSONArray();
JSONObject container = new JSONObject();
container.put("id", 0);
container.put("type", "container");
container.put("direction", 1);
container.put("main_alignment", 1);
container.put("cross_alignment", 1);
container.put("visible", true);
container.put("inversion", false);
JSONObject dimension = new JSONObject();
dimension.put("type", "rigid");
dimension.put("width", 240);
dimension.put("height", 240);
container.put("dimension", dimension);
JSONObject placement = new JSONObject();
placement.put("type", "absolute");
placement.put("left", 0);
placement.put("top", 0);
container.put("placement", placement);
imageLayout.put(container);
JSONObject image = new JSONObject();
image.put("id", 1);
image.put("parent_id", 0);
image.put("type", "image");
image.put("image_name", "#name");
image.put("draw_mode", 1);
image.put("visible", true);
image.put("inversion", false);
placement = new JSONObject();
placement.put("type", "absolute");
placement.put("left", "#pos.Ue");
placement.put("top", "#pos.Qe");
image.put("placement", placement);
dimension = new JSONObject();
dimension.put("width", "#size.w");
dimension.put("height", "#size.h");
image.put("dimension", dimension);
imageLayout.put(image);
return imageLayout.toString();
}
private String getConfiguration() throws JSONException {
JSONObject configuration = new JSONObject();
JSONArray layout = new JSONArray();
JSONObject background = new JSONObject();
background.put("type", "image");
background.put("name", "background.raw");
JSONObject size = new JSONObject();
size.put("w", 240);
size.put("h", 240);
background.put("size", size);
JSONObject pos = new JSONObject();
pos.put("x", 120);
pos.put("y", 120);
background.put("pos", pos);
layout.put(background);
for (JSONObject widget : widgets) {
layout.put(widget);
}
configuration.put("layout", layout);
return configuration.toString();
}
}

View File

@ -0,0 +1,41 @@
/* Copyright (C) 2021 Arjan Schrijver
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 <http://www.gnu.org/licenses/>. */
package nodomain.freeyourgadget.gadgetbridge.devices.qhybrid;
public class HybridHRWatchfaceWidget {
private String widgetType;
private int posX;
private int posY;
public HybridHRWatchfaceWidget(String widgetType, int posX, int posY) {
this.widgetType = widgetType;
this.posX = posX;
this.posY = posY;
}
public String getWidgetType() {
return widgetType;
}
public int getPosX() {
return posX;
}
public int getPosY() {
return posY;
}
}

View File

@ -176,6 +176,11 @@ public class QHybridCoordinator extends AbstractDeviceCoordinator {
return isHybridHR() ? AppManagerActivity.class : ConfigActivity.class;
}
@Override
public Class<? extends Activity> getWatchfaceDesignerActivity() {
return isHybridHR() ? HybridHRWatchfaceDesignerActivity.class : null;
}
/**
* Returns the directory containing the watch app cache.
* @throws IOException when the external files directory cannot be accessed

View File

@ -1,4 +1,4 @@
/* Copyright (C) 2019-2021 Andreas Shimokawa, Carsten Pfeiffer, Daniel Dakhno
/* Copyright (C) 2019-2021 Andreas Shimokawa, Carsten Pfeiffer, Daniel Dakhno, Arjan Schrijver
This file is part of Gadgetbridge.
@ -153,7 +153,6 @@ public class FossilHRWatchAdapter extends FossilWatchAdapter {
private CallSpec currentCallSpec = null;
private MusicSpec currentSpec = null;
int imageNameIndex = 0;
private byte jsonIndex = 0;
private AssetImage backGroundImage = null;
@ -1243,7 +1242,6 @@ public class FossilHRWatchAdapter extends FossilWatchAdapter {
});*/
}
public byte[] getSecretKey() throws IllegalAccessException {
byte[] authKeyBytes = new byte[16];

View File

@ -17,78 +17,24 @@
package nodomain.freeyourgadget.gadgetbridge.service.devices.qhybrid.requests.fossil_hr.image;
import android.graphics.Bitmap;
import android.graphics.BitmapFactory;
import android.graphics.Color;
import android.graphics.ColorSpace;
import androidx.annotation.ColorInt;
import java.io.File;
import java.io.FileOutputStream;
import java.io.IOException;
import static nodomain.freeyourgadget.gadgetbridge.service.devices.qhybrid.requests.fossil_hr.image.ImageConverter.encodeToRLEImage;
import static nodomain.freeyourgadget.gadgetbridge.service.devices.qhybrid.requests.fossil_hr.image.ImageConverter.encodeToRawImage;
import static nodomain.freeyourgadget.gadgetbridge.service.devices.qhybrid.requests.fossil_hr.image.ImageConverter.get2BitsRAWImageBytes;
import static nodomain.freeyourgadget.gadgetbridge.service.devices.qhybrid.requests.fossil_hr.image.ImageConverter.get2BitsRLEImageBytes;
public class AssetImageFactory {
public static AssetImage createAssetImage(byte[] fileData, int angle, int distance, int indexZ){
return new AssetImage(fileData, angle, distance, indexZ);
}
// method created for creating empty files, which turned out not to work anyway
private static AssetImage createAssetImage(byte[] fileData, String fileName, int angle, int distance, int indexZ){
return new AssetImage(fileData, fileName, angle, distance, indexZ);
}
public static AssetImage createAssetImage(Bitmap fileData, boolean RLEencode, int angle, int distance, int indexZ) throws IOException {
if(RLEencode == (distance == 0)) throw new RuntimeException("when RLEencoding distance must be 0, image must be at center of screen");
if(RLEencode){
int height = fileData.getHeight();
int width = fileData.getWidth();
// if(fileData.getConfig() != Bitmap.Config.ALPHA_8) throw new RuntimeException("Bitmap is not ALPHA_8");
int[] pixels = new int[height * width];
fileData.getPixels(pixels, 0, width, 0, 0, width, height);
byte[] pixelBytes = new byte[width * height];
for(int i = 0; i < pixels.length; i++){
int monochrome = convertToMonochrome(pixels[i]);
monochrome >>= 6;
int alpha = Color.alpha(pixels[i]);
monochrome |= (~((alpha & 0xFF) >> 4) & 0b00001100);
pixelBytes[i] = (byte) monochrome;
}
return new AssetImage(ImageConverter.encodeToRLEImage(pixelBytes, height, width), angle, distance, indexZ);
return new AssetImage(encodeToRLEImage(get2BitsRLEImageBytes(fileData), fileData.getHeight(), fileData.getWidth()), angle, distance, indexZ);
}else{
// applies only to big background
int width = 240;
int height = 240;
byte[] pixelBytes = new byte[width * height];
float jumpX = fileData.getWidth() / (float) width;
float jumpY = fileData.getHeight() / (float) height;
for(int y = 0; y < height; y++){
for(int x = 0; x < width; x++){
int pixel = fileData.getPixel((int)(x * jumpX), (int)(y * jumpY));
int monochrome = convertToMonochrome(pixel);
pixelBytes[pixelBytes.length - 1 - (y * width + x)] = (byte) monochrome;
}
}
return new AssetImage(ImageConverter.encodeToRawImage(pixelBytes), angle, distance, indexZ);
return new AssetImage(encodeToRawImage(get2BitsRAWImageBytes(fileData)), angle, distance, indexZ);
}
}
private static @ColorInt int convertToMonochrome(@ColorInt int color){
int sum = Color.red(color) + Color.green(color) + Color.blue(color);
sum /= 3;
return sum;
}
}

View File

@ -1,4 +1,4 @@
/* Copyright (C) 2020-2021 Daniel Dakhno
/* Copyright (C) 2020-2021 Daniel Dakhno, Arjan Schrijver
This file is part of Gadgetbridge.
@ -17,6 +17,9 @@
package nodomain.freeyourgadget.gadgetbridge.service.devices.qhybrid.requests.fossil_hr.image;
import android.graphics.Bitmap;
import android.graphics.Color;
import androidx.annotation.ColorInt;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
@ -24,8 +27,35 @@ import java.io.IOException;
import nodomain.freeyourgadget.gadgetbridge.service.devices.qhybrid.encoder.RLEEncoder;
public class ImageConverter {
public static void encodeToTwoBitImage(byte monochromeImage){
public static byte[] get2BitsRLEImageBytes(Bitmap bitmap) {
int[] pixels = new int[bitmap.getWidth() * bitmap.getHeight()];
bitmap.getPixels(pixels, 0, bitmap.getWidth(), 0, 0, bitmap.getWidth(), bitmap.getHeight());
byte[] b_pixels = new byte[pixels.length];
for (int i = 0; i < pixels.length; i++) {
int monochrome = convertToMonochrome(pixels[i]);
monochrome >>= 6;
int alpha = Color.alpha(pixels[i]);
monochrome |= (~((alpha & 0xFF) >> 4) & 0b00001100);
b_pixels[i] = (byte) monochrome;
}
return b_pixels;
}
public static byte[] get2BitsRAWImageBytes(Bitmap bitmap) {
int width = bitmap.getWidth();
int height = bitmap.getHeight();
byte[] pixelBytes = new byte[width * height];
for(int y = 0; y < height; y++){
for(int x = 0; x < width; x++){
int pixel = bitmap.getPixel(x, y);
int monochrome = convertToMonochrome(pixel);
pixelBytes[pixelBytes.length - 1 - (y * width + x)] = (byte) monochrome;
}
}
return pixelBytes;
}
public static byte[] encodeToRLEImage(byte[] monochromeImage, int height, int width) throws IOException {
@ -57,4 +87,10 @@ public class ImageConverter {
return result;
}
public static @ColorInt int convertToMonochrome(@ColorInt int color){
int sum = Color.red(color) + Color.green(color) + Color.blue(color);
sum /= 3;
return sum;
}
}

View File

@ -1,4 +1,4 @@
/* Copyright (C) 2019-2021 Daniel Dakhno
/* Copyright (C) 2019-2021 Daniel Dakhno, Arjan Schrijver
This file is part of Gadgetbridge.
@ -17,16 +17,13 @@
package nodomain.freeyourgadget.gadgetbridge.service.devices.qhybrid.requests.fossil_hr.notification;
import android.graphics.Bitmap;
import android.graphics.Canvas;
import android.graphics.ColorMatrix;
import android.graphics.ColorMatrixColorFilter;
import android.graphics.Paint;
import android.graphics.drawable.Drawable;
import nodomain.freeyourgadget.gadgetbridge.service.devices.qhybrid.requests.fossil_hr.file.AssetFile;
import nodomain.freeyourgadget.gadgetbridge.service.devices.qhybrid.requests.fossil_hr.image.ImageConverter;
import nodomain.freeyourgadget.gadgetbridge.util.BitmapUtil;
import static nodomain.freeyourgadget.gadgetbridge.service.devices.qhybrid.encoder.RLEEncoder.RLEEncode;
import static nodomain.freeyourgadget.gadgetbridge.util.BitmapUtil.convertDrawableToBitmap;
public class NotificationImage extends AssetFile {
public static final int MAX_ICON_WIDTH = 24;
@ -41,7 +38,7 @@ public class NotificationImage extends AssetFile {
}
public NotificationImage(String fileName, Bitmap iconBitmap) {
super(fileName, RLEEncode(get2BitsPixelsFromBitmap(convertIcon(iconBitmap))));
super(fileName, RLEEncode(ImageConverter.get2BitsRLEImageBytes(BitmapUtil.scaleWithMax(iconBitmap, MAX_ICON_WIDTH, MAX_ICON_HEIGHT))));
this.width = Math.min(iconBitmap.getWidth(), MAX_ICON_WIDTH);
this.height = Math.min(iconBitmap.getHeight(), MAX_ICON_HEIGHT);
}
@ -51,41 +48,8 @@ public class NotificationImage extends AssetFile {
public int getWidth() { return width; }
public int getHeight() { return height; }
private static Bitmap convertIcon(Bitmap bitmap) {
// Scale image only if necessary
if ((bitmap.getWidth() > MAX_ICON_WIDTH) || (bitmap.getHeight() > MAX_ICON_HEIGHT)) {
bitmap = Bitmap.createScaledBitmap(bitmap, MAX_ICON_WIDTH, MAX_ICON_HEIGHT, true);
}
// Convert to grayscale
Canvas c = new Canvas(bitmap);
Paint paint = new Paint();
ColorMatrix cm = new ColorMatrix();
cm.setSaturation(0);
ColorMatrixColorFilter f = new ColorMatrixColorFilter(cm);
paint.setColorFilter(f);
c.drawBitmap(bitmap, 0, 0, paint);
// Increase brightness
// bitmap = changeBitmapContrastBrightness(bitmap, 1, -50);
// Return result
return bitmap;
}
public static byte[] get2BitsPixelsFromBitmap(Bitmap bitmap) {
// Downsample to 2 bits image
int[] pixels = new int[bitmap.getWidth() * bitmap.getHeight()];
bitmap.getPixels(pixels, 0, bitmap.getWidth(), 0, 0, bitmap.getWidth(), bitmap.getHeight());
byte[] b_pixels = new byte[pixels.length];
for (int i = 0; i < pixels.length; i++) {
b_pixels[i] = (byte) (pixels[i] >> 6 & 0x03);
}
return b_pixels;
}
public static byte[] getEncodedIconFromDrawable(Drawable drawable) {
Bitmap icIncomingCallBitmap = convertDrawableToBitmap(drawable);
if ((icIncomingCallBitmap.getWidth() > MAX_ICON_WIDTH) || (icIncomingCallBitmap.getHeight() > MAX_ICON_HEIGHT)) {
icIncomingCallBitmap = Bitmap.createScaledBitmap(icIncomingCallBitmap, MAX_ICON_WIDTH, MAX_ICON_HEIGHT, true);
}
return RLEEncode(NotificationImage.get2BitsPixelsFromBitmap(icIncomingCallBitmap));
Bitmap iconBitmap = BitmapUtil.scaleWithMax(BitmapUtil.convertDrawableToBitmap(drawable), MAX_ICON_WIDTH, MAX_ICON_HEIGHT);
return RLEEncode(ImageConverter.get2BitsRLEImageBytes(iconBitmap));
}
}

View File

@ -22,10 +22,29 @@ import android.graphics.Canvas;
import android.graphics.ColorMatrix;
import android.graphics.ColorMatrixColorFilter;
import android.graphics.Paint;
import android.graphics.PorterDuff;
import android.graphics.PorterDuffXfermode;
import android.graphics.Rect;
import android.graphics.RectF;
import android.graphics.drawable.BitmapDrawable;
import android.graphics.drawable.Drawable;
public class BitmapUtil {
/**
* Downscale a bitmap to a maximum resolution. Doesn't scale if the bitmap is already smaller than the max resolution.
*
* @param bitmap
* @param maxWidth
* @param maxHeight
* @return
*/
public static Bitmap scaleWithMax(Bitmap bitmap, int maxWidth, int maxHeight) {
// Scale image only if necessary
if ((bitmap.getWidth() > maxWidth) || (bitmap.getHeight() > maxHeight)) {
bitmap = Bitmap.createScaledBitmap(bitmap, maxWidth, maxHeight, true);
}
return bitmap;
}
/**
* Get a Bitmap from any given Drawable.
@ -50,6 +69,23 @@ public class BitmapUtil {
return bitmap;
}
/**
* Converts the provided Bitmap to grayscale.
*
* @param bitmap
* @return
*/
public static Bitmap convertToGrayscale(Bitmap bitmap) {
Canvas c = new Canvas(bitmap);
Paint paint = new Paint();
ColorMatrix cm = new ColorMatrix();
cm.setSaturation(0);
ColorMatrixColorFilter f = new ColorMatrixColorFilter(cm);
paint.setColorFilter(f);
c.drawBitmap(bitmap, 0, 0, paint);
return bitmap;
}
/**
* Change the contrast and brightness on a Bitmap
*
@ -81,4 +117,37 @@ public class BitmapUtil {
return ret;
}
/**
* Crops a circular image from the center of the provided Bitmap.
* From: https://www.tutorialspoint.com/android-how-to-crop-circular-area-from-bitmap
* @param srcBitmap
* @return
*/
public static Bitmap getCircularBitmap(Bitmap srcBitmap) {
// Calculate the circular bitmap width with border
int squareBitmapWidth = Math.min(srcBitmap.getWidth(), srcBitmap.getHeight());
// Initialize a new instance of Bitmap
Bitmap dstBitmap = Bitmap.createBitmap (
squareBitmapWidth, // Width
squareBitmapWidth, // Height
Bitmap.Config.ARGB_8888 // Config
);
Canvas canvas = new Canvas(dstBitmap);
// Initialize a new Paint instance
Paint paint = new Paint();
paint.setAntiAlias(true);
Rect rect = new Rect(0, 0, squareBitmapWidth, squareBitmapWidth);
RectF rectF = new RectF(rect);
canvas.drawOval(rectF, paint);
paint.setXfermode(new PorterDuffXfermode(PorterDuff.Mode.SRC_IN));
// Calculate the left and top of copied bitmap
float left = (squareBitmapWidth-srcBitmap.getWidth())/2;
float top = (squareBitmapWidth-srcBitmap.getHeight())/2;
canvas.drawBitmap(srcBitmap, left, top, paint);
// Free the native object associated with this bitmap.
srcBitmap.recycle();
// Return the circular bitmap
return dstBitmap;
}
}

View File

@ -33,7 +33,20 @@
android:layout_alignParentBottom="true"
android:layout_alignParentEnd="true"
android:layout_gravity="bottom|end"
app:srcCompat="@drawable/ic_add"
app:srcCompat="@android:drawable/stat_sys_upload"
android:layout_margin="16dp" />
<com.google.android.material.floatingactionbutton.FloatingActionButton
android:id="@+id/fab_new"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_alignParentEnd="true"
android:layout_alignParentBottom="true"
android:layout_gravity="bottom|end"
android:layout_marginEnd="16dp"
android:layout_marginBottom="90dp"
app:srcCompat="@drawable/ic_add"
android:visibility="invisible"
/>
</android.widget.RelativeLayout>

View File

@ -0,0 +1,79 @@
<?xml version="1.0" encoding="utf-8"?>
<ScrollView
xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="wrap_content">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
xmlns:tools="http://schemas.android.com/tools"
tools:context=".devices.qhybrid.HybridHRWatchfaceDesignerActivity">
<RelativeLayout
android:layout_width="match_parent"
android:layout_height="wrap_content">
<TextView
android:id="@+id/watchface_name"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_centerVertical="true"
android:layout_marginStart="10dp"
android:text="NewWatchface"
android:textSize="16sp" />
<Button
android:id="@+id/button_edit_name"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_alignParentEnd="true"
android:text="Edit name" />
</RelativeLayout>
<RelativeLayout
android:id="@+id/watchface_preview_image"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="20dp"
android:layout_marginBottom="20dp">
<ImageView
android:id="@+id/hybridhr_background_image"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_centerHorizontal="true" />
</RelativeLayout>
<Button
android:id="@+id/button_set_background"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="Select background image" />
<Button
android:id="@+id/button_add_widget"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="Add widget" />
<Button
android:id="@+id/button_watchface_settings"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:enabled="false"
android:text="Watchface settings" />
<Button
android:id="@+id/button_preview_watchface"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="Preview on watch" />
<Button
android:id="@+id/button_save_watchface"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="Save and send to watch" />
</LinearLayout>
</ScrollView>