Cloud sync made right

This commit is contained in:
utkabobr
2025-04-06 20:02:28 +03:00
parent a27a8c1d5d
commit 70f4d08a8c
16 changed files with 350 additions and 57 deletions
@@ -85,6 +85,8 @@ public class MainActivity extends AppCompatActivity {
public static List<ConfigObject> EXPORTING_FILAMENTS;
public static List<ConfigObject> EXPORTING_PRINTERS;
public static boolean IS_GENERATING_AI_MODEL;
public static File aiTempFile;
private static SparseArray<NavigationDelegate> liveDelegate = new SparseArray<>();
@@ -489,6 +491,7 @@ public class MainActivity extends AppCompatActivity {
}
private void generateAiModel(Bitmap bm) {
IS_GENERATING_AI_MODEL = true;
String uploadTag = UUID.randomUUID().toString();
SliceBeam.EVENT_BUS.fireEvent(new NeedSnackbarEvent(SnackbarsLayout.Type.LOADING, R.string.MenuFileAIGeneratorUploading).tag(uploadTag));
IOUtils.IO_POOL.submit(()->{
@@ -572,6 +575,7 @@ public class MainActivity extends AppCompatActivity {
SliceBeam.EVENT_BUS.fireEvent(new NeedSnackbarEvent(R.string.MenuFileAIGeneratorSavedAs, fileName));
loadFile(f, true);
CloudController.checkGeneratorRemaining();
IS_GENERATING_AI_MODEL = false;
}
@Override
@@ -582,6 +586,7 @@ public class MainActivity extends AppCompatActivity {
.setMessage(e.toString())
.setPositiveButton(android.R.string.ok, null)
.show());
IS_GENERATING_AI_MODEL = false;
}
});
SliceBeam.EVENT_BUS.fireEvent(new NeedDismissSnackbarEvent(uploadTag));
@@ -22,6 +22,7 @@ import android.graphics.Typeface;
import android.graphics.drawable.Drawable;
import android.net.Uri;
import android.opengl.GLSurfaceView;
import android.os.Build;
import android.os.Bundle;
import android.text.SpannableStringBuilder;
import android.text.TextUtils;
@@ -653,7 +654,7 @@ public class SetupActivity extends AppCompatActivity {
private FrameLayout buttonView;
private TextView buttonText;
private ProgressBar buttonProgress;
private RecyclerView recyclerView;
private FadeRecyclerView recyclerView;
@Override
public View onCreateView(Context ctx) {
@@ -670,8 +671,8 @@ public class SetupActivity extends AppCompatActivity {
ll.addView(title);
FrameLayout fl = new FrameLayout(ctx);
recyclerView = new RecyclerView(ctx);
recyclerView.setLayoutManager(new LinearLayoutManager(ctx));
recyclerView = new FadeRecyclerView(ctx);
recyclerView.setBitmapMode();
recyclerView.setAdapter(adapter = new SimpleRecyclerAdapter());
recyclerView.setOverScrollMode(View.OVER_SCROLL_NEVER);
fl.addView(recyclerView, new FrameLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT, Gravity.CENTER));
@@ -901,6 +902,14 @@ public class SetupActivity extends AppCompatActivity {
CloudAPI.UserFeatures features = CloudController.getUserFeatures();
CloudAPI.UserInfo info = CloudController.getUserInfo();
Context ctx = getContext();
if (!BuildConfig.IS_GOOGLE_PLAY && features.earlyAccessLevel != -1 && lvl.level >= features.earlyAccessLevel) {
items.add(new PreferenceItem()
.setForceDark(true)
.setPaddings(ViewUtils.dp(8))
.setIcon(R.drawable.clock_circle_dashed_outline_24)
.setTitle(ctx.getString(R.string.SettingsCloudManageFeatureEarlyAccess))
.setSubtitle(ctx.getString(R.string.SettingsCloudManageFeatureEarlyAccessDescription)));
}
if (features.syncRequiredLevel != -1 && lvl.level >= features.syncRequiredLevel) {
items.add(new PreferenceItem()
.setForceDark(true)
@@ -0,0 +1,12 @@
package ru.ytkab0bp.slicebeam.boot;
import java.util.Collections;
import ru.ytkab0bp.slicebeam.cloud.CloudController;
public class CloudCachedInitTask extends BootTask {
public CloudCachedInitTask() {
super(Collections.singletonList(PrefsTask.class), CloudController::initCached);
onWorker();
}
}
@@ -6,7 +6,7 @@ import ru.ytkab0bp.slicebeam.cloud.CloudController;
public class CloudInitTask extends BootTask {
public CloudInitTask() {
super(Arrays.asList(PrefsTask.class, TrueTimeTask.class, LoadSlic3rConfigTask.class), CloudController::init);
super(Arrays.asList(PrefsTask.class, TrueTimeTask.class, LoadSlic3rConfigTask.class, CloudCachedInitTask.class), CloudController::init);
onWorker();
nonCritical = true;
}
@@ -10,6 +10,7 @@ import java.net.InetAddress;
import java.util.Date;
import java.util.List;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.TimeUnit;
import kotlinx.coroutines.Dispatchers;
import ru.ytkab0bp.slicebeam.SliceBeam;
@@ -65,7 +66,7 @@ public class TrueTimeTask extends BootTask {
});
SliceBeam.TRUE_TIME.sync();
try {
latch.await();
latch.await(300, TimeUnit.MILLISECONDS);
} catch (InterruptedException ignored) {}
});
onWorker();
@@ -92,8 +92,8 @@ public interface CloudAPI extends APIRunner {
* <p>
* Requires authorization
*/
@Method("sync/upload")
void syncUpload(@Arg("data") String data, APICallback<SyncState> callback);
@Method(requestType = RequestType.POST, value = "sync/upload")
void syncUpload(@Arg("") String data, @Header("Content-Type") String type, APICallback<SyncState> callback);
/**
* Downloads base64 data
@@ -159,6 +159,11 @@ public interface CloudAPI extends APIRunner {
}
final class UserFeatures {
/**
* Which level is required for early access
*/
public int earlyAccessLevel;
/**
* Which level is required for data sync
*/
@@ -2,27 +2,40 @@ package ru.ytkab0bp.slicebeam.cloud;
import android.content.Intent;
import android.net.Uri;
import android.util.Base64;
import android.util.Log;
import com.google.gson.Gson;
import java.io.ByteArrayOutputStream;
import java.io.File;
import java.io.FileInputStream;
import java.io.FileOutputStream;
import java.io.IOException;
import java.text.SimpleDateFormat;
import java.util.Date;
import java.util.Locale;
import ru.ytkab0bp.sapil.APICallback;
import ru.ytkab0bp.sapil.APIRequestHandle;
import ru.ytkab0bp.slicebeam.R;
import ru.ytkab0bp.slicebeam.SliceBeam;
import ru.ytkab0bp.slicebeam.components.BeamAlertDialogBuilder;
import ru.ytkab0bp.slicebeam.events.CloudFeaturesUpdatedEvent;
import ru.ytkab0bp.slicebeam.events.CloudLoginStateUpdatedEvent;
import ru.ytkab0bp.slicebeam.events.CloudModelsRemainingCountUpdatedEvent;
import ru.ytkab0bp.slicebeam.events.CloudUserInfoUpdatedEvent;
import ru.ytkab0bp.slicebeam.events.NeedDismissSnackbarEvent;
import ru.ytkab0bp.slicebeam.events.NeedSnackbarEvent;
import ru.ytkab0bp.slicebeam.slic3r.Slic3rConfigWrapper;
import ru.ytkab0bp.slicebeam.utils.IOUtils;
import ru.ytkab0bp.slicebeam.utils.Prefs;
import ru.ytkab0bp.slicebeam.utils.ViewUtils;
import ru.ytkab0bp.slicebeam.view.SnackbarsLayout;
public class CloudController {
public final static String USER_INFO_AI_GEN_TAG = "ai_gen_user_info";
private final static String CLOUD_SYNC_TAG = "cloud_sync";
public final static String CLOUD_SYNC_TAG = "cloud_sync";
private final static String TAG = "cloud";
private final static long MIN_SYNC_DELTA = 5 * 60 * 1000L; // Once in 5 minutes
@@ -71,11 +84,20 @@ public class CloudController {
private static Gson gson = new Gson();
public static void init() {
public static void initCached() {
if (Prefs.getCloudCachedUserFeatures() != null) {
userFeatures = gson.fromJson(Prefs.getCloudCachedUserFeatures(), CloudAPI.UserFeatures.class);
SliceBeam.EVENT_BUS.fireEvent(new CloudFeaturesUpdatedEvent());
}
if (Prefs.getCloudAPIToken() != null) {
if (Prefs.getCloudCachedUserInfo() != null) {
userInfo = gson.fromJson(Prefs.getCloudCachedUserInfo(), CloudAPI.UserInfo.class);
modelsUsed = Prefs.getCloudCachedUsedModels();
modelsMaxGenerations = Prefs.getCloudCachedMaxModels();
}
}
}
public static void init() {
long now = SliceBeam.TRUE_TIME.now().getTime();
boolean needSyncInfo = userFeatures == null || now - Prefs.getCloudLastFeaturesSync() > MIN_SYNC_FEATURES_DELTA;
if (needSyncInfo) {
@@ -83,15 +105,15 @@ public class CloudController {
}
if (Prefs.getCloudAPIToken() != null) {
if (Prefs.getCloudCachedUserInfo() != null) {
userInfo = gson.fromJson(Prefs.getCloudCachedUserInfo(), CloudAPI.UserInfo.class);
modelsUsed = Prefs.getCloudCachedUsedModels();
modelsMaxGenerations = Prefs.getCloudCachedMaxModels();
}
if (needSyncInfo || userInfo == null) {
loadUserInfo();
}
if (!needSyncInfo && userInfo != null && isSyncAvailable() && Prefs.isCloudProfileSyncEnabled()) {
if (now - Prefs.getCloudLastSync() > MIN_SYNC_DELTA) {
syncData();
}
}
}
}
@@ -123,10 +145,7 @@ public class CloudController {
}
if (isSyncAvailable() && Prefs.isCloudProfileSyncEnabled()) {
long now = SliceBeam.TRUE_TIME.now().getTime();
if (now != Prefs.getLocalLastModified()) {
sendData();
}
syncData();
}
checkGeneratorRemaining();
}
@@ -254,30 +273,140 @@ public class CloudController {
return modelsMaxGenerations;
}
private static void sendData() {
if (isSyncInProgress) {
return;
}
// TODO: IMPORTANT: Check getState first, then show conflict info
long modified = Prefs.getLocalLastModified();
isSyncInProgress = true;
SliceBeam.EVENT_BUS.fireEvent(new NeedSnackbarEvent(SnackbarsLayout.Type.LOADING, R.string.CloudSyncInProgress).tag(CLOUD_SYNC_TAG));
CloudAPI.INSTANCE.syncUpload("", new APICallback<CloudAPI.SyncState>() {
private static void downloadData(long lastModified) {
CloudAPI.INSTANCE.syncGet(new APICallback<String>() {
@Override
public void onResponse(CloudAPI.SyncState response) {
isSyncInProgress = false;
if (Prefs.getLocalLastModified() != modified) { // Re-send otherwise
sendData();
return;
}
Prefs.setCloudLastSync(modified);
SliceBeam.EVENT_BUS.fireEvent(new NeedDismissSnackbarEvent(CLOUD_SYNC_TAG));
SliceBeam.EVENT_BUS.fireEvent(new NeedSnackbarEvent(R.string.CloudSyncSuccess));
public void onResponse(String response) {
IOUtils.IO_POOL.submit(() -> {
try {
File f = SliceBeam.getConfigFile();
byte[] data = Base64.decode(response, 0);
FileOutputStream fos = new FileOutputStream(f);
fos.write(data);
fos.close();
SliceBeam.CONFIG = new Slic3rConfigWrapper(f);
Prefs.setCloudLocalLastModified(lastModified);
Prefs.setCloudLocalLastSentModified(lastModified);
Prefs.setCloudRemoteLastModified(lastModified);
Prefs.setCloudLastSync(SliceBeam.TRUE_TIME.now().getTime());
SliceBeam.EVENT_BUS.fireEvent(new NeedDismissSnackbarEvent(CLOUD_SYNC_TAG));
SliceBeam.EVENT_BUS.fireEvent(new NeedSnackbarEvent(R.string.CloudSyncSuccess));
} catch (IOException e) {
Log.e(TAG, "Failed to write data", e);
isSyncInProgress = false;
SliceBeam.EVENT_BUS.fireEvent(new NeedDismissSnackbarEvent(CLOUD_SYNC_TAG));
SliceBeam.EVENT_BUS.fireEvent(new NeedSnackbarEvent(SnackbarsLayout.Type.ERROR, R.string.CloudSyncError));
}
});
}
@Override
public void onException(Exception e) {
Log.e(TAG, "Failed to upload sync data", e);
Log.e(TAG, "Failed to download data", e);
isSyncInProgress = false;
SliceBeam.EVENT_BUS.fireEvent(new NeedDismissSnackbarEvent(CLOUD_SYNC_TAG));
SliceBeam.EVENT_BUS.fireEvent(new NeedSnackbarEvent(SnackbarsLayout.Type.ERROR, R.string.CloudSyncError));
}
});
}
private static void syncData() {
if (isSyncInProgress) {
return;
}
long modified = Prefs.getCloudLocalLastModified();
isSyncInProgress = true;
SliceBeam.EVENT_BUS.fireEvent(new NeedSnackbarEvent(SnackbarsLayout.Type.LOADING, R.string.CloudSyncInProgress).tag(CLOUD_SYNC_TAG));
CloudAPI.INSTANCE.syncGetState(new APICallback<CloudAPI.SyncState>() {
@Override
public void onResponse(CloudAPI.SyncState response) {
if (response.usedSize == 0) {
// No data on server yet, send anyway
uploadData(modified);
} else if (response.lastUpdatedDate != Prefs.getCloudRemoteLastModified()) {
if (Prefs.getCloudLocalLastSentModified() == modified) {
// Modified only on server
downloadData(response.lastUpdatedDate);
} else {
// Modified on client and on server
SliceBeam.EVENT_BUS.fireEvent(new NeedDismissSnackbarEvent(CLOUD_SYNC_TAG));
SliceBeam.EVENT_BUS.fireEvent(new NeedSnackbarEvent(SnackbarsLayout.Type.WARNING, R.string.CloudSyncConflict).button(R.string.CloudSyncConflictResolve, v -> {
SimpleDateFormat format = new SimpleDateFormat("dd.MM.yyyy HH:mm", Locale.getDefault());
new BeamAlertDialogBuilder(v.getContext())
.setTitle(R.string.CloudSyncConflict)
.setMessage(v.getContext().getString(R.string.CloudSyncConflictResolveMessage, format.format(new Date(response.lastUpdatedDate)), format.format(new Date(Prefs.getCloudLocalLastModified()))))
.setPositiveButton(R.string.CloudSyncConflictChooseRemote, (dialog, which) -> downloadData(response.lastUpdatedDate))
.setNegativeButton(R.string.CloudSyncConflictChooseLocal, (dialog, which) -> uploadData(modified))
.show();
}).tag(CLOUD_SYNC_TAG));
}
} else {
if (Prefs.getCloudLocalLastSentModified() != modified) {
// Modified only on client
uploadData(modified);
} else {
// Not modified on server and on client
SliceBeam.EVENT_BUS.fireEvent(new NeedDismissSnackbarEvent(CLOUD_SYNC_TAG));
}
}
}
@Override
public void onException(Exception e) {
Log.e(TAG, "Failed to get sync state", e);
isSyncInProgress = false;
SliceBeam.EVENT_BUS.fireEvent(new NeedDismissSnackbarEvent(CLOUD_SYNC_TAG));
SliceBeam.EVENT_BUS.fireEvent(new NeedSnackbarEvent(SnackbarsLayout.Type.ERROR, R.string.CloudSyncError));
}
});
}
private static void uploadData(long modified) {
IOUtils.IO_POOL.submit(() -> {
try {
File f = SliceBeam.getConfigFile();
FileInputStream fis = new FileInputStream(f);
ByteArrayOutputStream bos = new ByteArrayOutputStream();
byte[] buffer = new byte[10240];
int c;
while ((c = fis.read(buffer)) != -1) {
bos.write(buffer, 0, c);
}
bos.close();
fis.close();
CloudAPI.INSTANCE.syncUpload(Base64.encodeToString(bos.toByteArray(), Base64.NO_WRAP), "application/ini", new APICallback<CloudAPI.SyncState>() {
@Override
public void onResponse(CloudAPI.SyncState response) {
isSyncInProgress = false;
if (Prefs.getCloudLocalLastModified() != modified) { // Re-send otherwise
syncData();
return;
}
Prefs.setCloudRemoteLastModified(response.lastUpdatedDate);
Prefs.setCloudLocalLastSentModified(modified);
Prefs.setCloudLastSync(SliceBeam.TRUE_TIME.now().getTime());
SliceBeam.EVENT_BUS.fireEvent(new NeedDismissSnackbarEvent(CLOUD_SYNC_TAG));
SliceBeam.EVENT_BUS.fireEvent(new NeedSnackbarEvent(R.string.CloudSyncSuccess));
}
@Override
public void onException(Exception e) {
Log.e(TAG, "Failed to upload sync data", e);
isSyncInProgress = false;
SliceBeam.EVENT_BUS.fireEvent(new NeedDismissSnackbarEvent(CLOUD_SYNC_TAG));
SliceBeam.EVENT_BUS.fireEvent(new NeedSnackbarEvent(SnackbarsLayout.Type.ERROR, R.string.CloudSyncError));
}
});
} catch (IOException e) {
Log.e(TAG, "Failed to read sync data", e);
isSyncInProgress = false;
SliceBeam.EVENT_BUS.fireEvent(new NeedDismissSnackbarEvent(CLOUD_SYNC_TAG));
@@ -288,12 +417,12 @@ public class CloudController {
public static void notifyDataChanged() {
long now = SliceBeam.TRUE_TIME.now().getTime();
Prefs.setLocalLastModified(now);
Prefs.setCloudLocalLastModified(now);
if (!isSyncAvailable() || !Prefs.isCloudProfileSyncEnabled()) {
return;
}
if (now - Prefs.getCloudLastSync() > MIN_SYNC_DELTA) {
sendData();
syncData();
}
}
}
@@ -25,8 +25,10 @@ import java.util.ArrayList;
import java.util.List;
import ru.ytkab0bp.slicebeam.R;
import ru.ytkab0bp.slicebeam.SliceBeam;
import ru.ytkab0bp.slicebeam.cloud.CloudAPI;
import ru.ytkab0bp.slicebeam.cloud.CloudController;
import ru.ytkab0bp.slicebeam.events.NeedDismissSnackbarEvent;
import ru.ytkab0bp.slicebeam.recycler.PreferenceSwitchItem;
import ru.ytkab0bp.slicebeam.recycler.SimpleRecyclerAdapter;
import ru.ytkab0bp.slicebeam.recycler.SimpleRecyclerItem;
@@ -94,6 +96,8 @@ public class CloudManageBottomSheet extends BottomSheetDialog {
Prefs.setCloudProfileSyncEnabled(isChecked);
if (isChecked) {
CloudController.notifyDataChanged();
} else {
SliceBeam.EVENT_BUS.fireEvent(new NeedDismissSnackbarEvent(CloudController.CLOUD_SYNC_TAG));
}
}));
}
@@ -105,8 +109,8 @@ public class CloudManageBottomSheet extends BottomSheetDialog {
adapter.setItems(items);
recyclerView.setAdapter(adapter);
ll.addView(recyclerView, new LinearLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT) {{
topMargin = ViewUtils.dp(12);
leftMargin = rightMargin = ViewUtils.dp(12);
topMargin = ViewUtils.dp(16);
leftMargin = rightMargin = ViewUtils.dp(16);
}});
}
@@ -303,6 +303,10 @@ public class FileMenu extends ListBedMenu {
SliceBeam.EVENT_BUS.fireEvent(new NeedSnackbarEvent(SnackbarsLayout.Type.ERROR, R.string.MenuFileAIGeneratorNoGenerationsLeft));
return;
}
if (MainActivity.IS_GENERATING_AI_MODEL) {
SliceBeam.EVENT_BUS.fireEvent(new NeedSnackbarEvent(SnackbarsLayout.Type.WARNING, R.string.MenuFileAIGeneratorAlreadyGenerating));
return;
}
if (ctx instanceof MainActivity) {
try {
MainActivity.aiTempFile = File.createTempFile("ai_capture", ".jpg");
@@ -320,6 +324,10 @@ public class FileMenu extends ListBedMenu {
SliceBeam.EVENT_BUS.fireEvent(new NeedSnackbarEvent(SnackbarsLayout.Type.ERROR, R.string.MenuFileAIGeneratorNoGenerationsLeft));
return;
}
if (MainActivity.IS_GENERATING_AI_MODEL) {
SliceBeam.EVENT_BUS.fireEvent(new NeedSnackbarEvent(SnackbarsLayout.Type.WARNING, R.string.MenuFileAIGeneratorAlreadyGenerating));
return;
}
if (ctx instanceof MainActivity) {
Intent intent = new Intent();
intent.setType("image/*");
@@ -1,5 +1,7 @@
package ru.ytkab0bp.slicebeam.events;
import android.view.View;
import ru.ytkab0bp.eventbus.Event;
import ru.ytkab0bp.slicebeam.SliceBeam;
import ru.ytkab0bp.slicebeam.view.SnackbarsLayout;
@@ -10,6 +12,9 @@ public class NeedSnackbarEvent {
public SnackbarsLayout.Type type = SnackbarsLayout.Type.DONE;
public String tag;
public CharSequence buttonTitle;
public View.OnClickListener buttonClick;
public NeedSnackbarEvent(SnackbarsLayout.Type type, CharSequence title) {
this.type = type;
this.title = title;
@@ -32,4 +37,10 @@ public class NeedSnackbarEvent {
this.tag = tag;
return this;
}
public NeedSnackbarEvent button(int title, View.OnClickListener click) {
this.buttonTitle = SliceBeam.INSTANCE.getString(title);
this.buttonClick = click;
return this;
}
}
@@ -144,6 +144,11 @@ public class BedFragment extends Fragment {
if (e.tag != null) {
s.tag(e.tag);
}
if (e.buttonTitle != null) {
s.lifetime = 0;
s.buttonTitle = e.buttonTitle;
s.buttonClick = e.buttonClick;
}
snackbarsLayout.show(s);
}
@@ -203,19 +203,27 @@ public class Prefs {
mPrefs.edit().putLong("cloud_last_sync", ls).apply();
}
public static long getLocalLastModified() {
public static long getCloudLocalLastSentModified() {
return mPrefs.getLong("cloud_local_last_sent_modified", 0);
}
public static void setCloudLocalLastSentModified(long lm) {
mPrefs.edit().putLong("cloud_local_last_sent_modified", lm).apply();
}
public static long getCloudLocalLastModified() {
return mPrefs.getLong("cloud_local_last_modified", 0);
}
public static void setLocalLastModified(long lm) {
public static void setCloudLocalLastModified(long lm) {
mPrefs.edit().putLong("cloud_local_last_modified", lm).apply();
}
public static long getRemoteLastModified() {
public static long getCloudRemoteLastModified() {
return mPrefs.getLong("cloud_remote_last_modified", 0);
}
public static void setRemoteLastModified(long lm) {
public static void setCloudRemoteLastModified(long lm) {
mPrefs.edit().putLong("cloud_remote_last_modified", lm).apply();
}
@@ -1,9 +1,13 @@
package ru.ytkab0bp.slicebeam.view;
import android.content.Context;
import android.graphics.Bitmap;
import android.graphics.Canvas;
import android.graphics.Color;
import android.graphics.LinearGradient;
import android.graphics.Paint;
import android.graphics.PorterDuff;
import android.graphics.PorterDuffXfermode;
import android.graphics.Shader;
import android.view.View;
@@ -25,6 +29,10 @@ public class FadeRecyclerView extends RecyclerView implements IThemeView {
private float topProgress, bottomProgress;
private float overlayAlpha = 1f;
private Bitmap bitmap;
private Canvas bitmapCanvas;
private boolean bitmapMode;
public FadeRecyclerView(@NonNull Context context) {
super(context);
@@ -52,6 +60,16 @@ public class FadeRecyclerView extends RecyclerView implements IThemeView {
onApplyTheme();
}
/**
* Very heavy, should be used only if transparent background is really needed
*/
public void setBitmapMode() {
this.bitmapMode = true;
topPaint.setXfermode(new PorterDuffXfermode(PorterDuff.Mode.DST_IN));
bottomPaint.setXfermode(new PorterDuffXfermode(PorterDuff.Mode.DST_IN));
invalidateShaders();
}
public void setOverlayAlpha(float overlayAlpha) {
this.overlayAlpha = overlayAlpha;
invalidate();
@@ -59,15 +77,56 @@ public class FadeRecyclerView extends RecyclerView implements IThemeView {
@Override
public void draw(Canvas c) {
super.draw(c);
Canvas cv;
if (bitmapMode) {
if (bitmap == null || bitmap.getWidth() != getWidth() || bitmap.getHeight() != getHeight()) {
if (bitmap != null) {
bitmap.recycle();
}
bitmap = Bitmap.createBitmap(getWidth(), getHeight(), Bitmap.Config.ARGB_8888);
bitmapCanvas = new Canvas(bitmap);
}
bitmap.eraseColor(Color.TRANSPARENT);
cv = bitmapCanvas;
super.draw(cv);
} else {
super.draw(cv = c);
}
if (topProgress > 0) {
topPaint.setAlpha((int) (topProgress * overlayAlpha * 0xFF));
c.drawRect(0, 0, getWidth(), ViewUtils.dp(HEIGHT_DP), topPaint);
cv.save();
if (bitmapMode) {
cv.translate(0, -ViewUtils.dp(HEIGHT_DP) * (1f - topProgress * overlayAlpha));
} else {
topPaint.setAlpha((int) (topProgress * overlayAlpha * 0xFF));
}
cv.drawRect(0, 0, getWidth(), ViewUtils.dp(HEIGHT_DP), topPaint);
cv.restore();
}
if (bottomProgress > 0) {
bottomPaint.setAlpha((int) (bottomProgress * overlayAlpha * 0xFF));
c.drawRect(0, getHeight() - ViewUtils.dp(HEIGHT_DP), getWidth(), getHeight(), bottomPaint);
cv.save();
if (bitmapMode) {
cv.translate(0, ViewUtils.dp(HEIGHT_DP) * (1f - bottomProgress * overlayAlpha));
} else {
bottomPaint.setAlpha((int) (bottomProgress * overlayAlpha * 0xFF));
}
cv.drawRect(0, getHeight() - ViewUtils.dp(HEIGHT_DP), getWidth(), getHeight(), bottomPaint);
cv.restore();
}
if (bitmapMode) {
c.drawBitmap(bitmap, 0, 0, null);
invalidate();
}
}
@Override
protected void onDetachedFromWindow() {
super.onDetachedFromWindow();
if (bitmap != null) {
bitmap.recycle();
bitmap = null;
bitmapCanvas = null;
}
}
@@ -80,8 +139,9 @@ public class FadeRecyclerView extends RecyclerView implements IThemeView {
private void invalidateShaders() {
if (getWidth() == 0 || getHeight() == 0) return;
topPaint.setShader(new LinearGradient(getWidth() / 2f, 0, getWidth() / 2f, ViewUtils.dp(HEIGHT_DP), ThemesRepo.getColor(android.R.attr.windowBackground), 0, Shader.TileMode.CLAMP));
bottomPaint.setShader(new LinearGradient(getWidth() / 2f, getHeight() - ViewUtils.dp(HEIGHT_DP), getWidth() / 2f, getHeight(), 0, ThemesRepo.getColor(android.R.attr.windowBackground), Shader.TileMode.CLAMP));
int clr = bitmapMode ? Color.BLACK : ThemesRepo.getColor(android.R.attr.windowBackground);
topPaint.setShader(new LinearGradient(getWidth() / 2f, 0, getWidth() / 2f, ViewUtils.dp(HEIGHT_DP), bitmapMode ? 0 : clr, bitmapMode ? clr : 0, Shader.TileMode.CLAMP));
bottomPaint.setShader(new LinearGradient(getWidth() / 2f, getHeight() - ViewUtils.dp(HEIGHT_DP), getWidth() / 2f, getHeight(), bitmapMode ? clr : 0, bitmapMode ? 0 : clr, Shader.TileMode.CLAMP));
invalidate();
}
@@ -16,6 +16,7 @@ import android.widget.FrameLayout;
import android.widget.ImageView;
import android.widget.LinearLayout;
import android.widget.ProgressBar;
import android.widget.Space;
import android.widget.TextView;
import androidx.annotation.NonNull;
@@ -104,6 +105,8 @@ public class SnackbarsLayout extends FrameLayout {
private TextView title;
private Snackbar snackbar;
private TextView button;
private float progress;
private GestureDetector gestureDetector;
@@ -143,6 +146,16 @@ public class SnackbarsLayout extends FrameLayout {
title.setEllipsize(TextUtils.TruncateAt.END);
addView(title);
addView(new Space(context), new LinearLayout.LayoutParams(0, 0, 1f));
button = new TextView(context);
button.setTextSize(TypedValue.COMPLEX_UNIT_DIP, 14);
button.setTypeface(ViewUtils.getTypeface(ViewUtils.ROBOTO_MEDIUM));
button.setMaxLines(1);
button.setEllipsize(TextUtils.TruncateAt.END);
button.setPadding(ViewUtils.dp(8), ViewUtils.dp(8), ViewUtils.dp(8),ViewUtils.dp(8));
addView(button);
setLayoutParams(new FrameLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT) {{
leftMargin = topMargin = rightMargin = bottomMargin = ViewUtils.dp(MARGIN_DP);
}});
@@ -164,7 +177,7 @@ public class SnackbarsLayout extends FrameLayout {
@Override
public boolean onFling(@Nullable MotionEvent e1, @NonNull MotionEvent e2, float velocityX, float velocityY) {
if (snackbar.type == Type.LOADING) {
if (snackbar.lifetime == 0) {
return false;
}
@@ -247,34 +260,48 @@ public class SnackbarsLayout extends FrameLayout {
icon.setVisibility(snackbar.type == Type.LOADING ? GONE : VISIBLE);
title.setText(snackbar.title);
button.setText(snackbar.buttonTitle);
button.setOnClickListener(snackbar.buttonClick);
button.setVisibility(snackbar.buttonTitle != null ? View.VISIBLE : View.GONE);
switch (snackbar.type) {
case DONE:
icon.setImageResource(R.drawable.done_outline_28);
icon.setImageTintList(ColorStateList.valueOf(ThemesRepo.getColor(R.attr.snackbarDone)));
title.setTextColor(ThemesRepo.getColor(R.attr.snackbarDone));
button.setTextColor(ThemesRepo.getColor(R.attr.snackbarDone));
button.setBackground(ViewUtils.createRipple(ThemesRepo.getColor(android.R.attr.colorControlHighlight), ColorUtils.setAlphaComponent(ThemesRepo.getColor(R.attr.snackbarDone), 0x21), 8));
setBackgroundColor(ColorUtils.blendARGB(ThemesRepo.getColor(R.attr.snackbarBase), ThemesRepo.getColor(R.attr.snackbarDone), 0.15f));
break;
case WARNING:
icon.setImageResource(R.drawable.warning_triangle_outline_28);
icon.setImageTintList(ColorStateList.valueOf(ThemesRepo.getColor(R.attr.snackbarWarning)));
title.setTextColor(ThemesRepo.getColor(R.attr.snackbarWarning));
button.setTextColor(ThemesRepo.getColor(R.attr.snackbarWarning));
button.setBackground(ViewUtils.createRipple(ThemesRepo.getColor(android.R.attr.colorControlHighlight), ColorUtils.setAlphaComponent(ThemesRepo.getColor(R.attr.snackbarWarning), 0x21), 8));
setBackgroundColor(ColorUtils.blendARGB(ThemesRepo.getColor(R.attr.snackbarBase), ThemesRepo.getColor(R.attr.snackbarWarning), 0.15f));
break;
case LOADING:
progressBar.setIndeterminateTintList(ColorStateList.valueOf(ThemesRepo.getColor(R.attr.snackbarInfo)));
title.setTextColor(ThemesRepo.getColor(R.attr.snackbarInfo));
button.setTextColor(ThemesRepo.getColor(R.attr.snackbarInfo));
button.setBackground(ViewUtils.createRipple(ThemesRepo.getColor(android.R.attr.colorControlHighlight), ColorUtils.setAlphaComponent(ThemesRepo.getColor(R.attr.snackbarInfo), 0x21), 8));
setBackgroundColor(ColorUtils.blendARGB(ThemesRepo.getColor(R.attr.snackbarBase), ThemesRepo.getColor(R.attr.snackbarInfo), 0.15f));
break;
case INFO:
icon.setImageResource(R.drawable.info_outline_28);
icon.setImageTintList(ColorStateList.valueOf(ThemesRepo.getColor(R.attr.snackbarInfo)));
title.setTextColor(ThemesRepo.getColor(R.attr.snackbarInfo));
button.setTextColor(ThemesRepo.getColor(R.attr.snackbarInfo));
button.setBackground(ViewUtils.createRipple(ThemesRepo.getColor(android.R.attr.colorControlHighlight), ColorUtils.setAlphaComponent(ThemesRepo.getColor(R.attr.snackbarInfo), 0x21), 8));
setBackgroundColor(ColorUtils.blendARGB(ThemesRepo.getColor(R.attr.snackbarBase), ThemesRepo.getColor(R.attr.snackbarInfo), 0.15f));
break;
case ERROR:
icon.setImageResource(R.drawable.error_outline_28);
icon.setImageTintList(ColorStateList.valueOf(ThemesRepo.getColor(R.attr.snackbarError)));
title.setTextColor(ThemesRepo.getColor(R.attr.snackbarError));
button.setTextColor(ThemesRepo.getColor(R.attr.snackbarError));
button.setBackground(ViewUtils.createRipple(ThemesRepo.getColor(android.R.attr.colorControlHighlight), ColorUtils.setAlphaComponent(ThemesRepo.getColor(R.attr.snackbarError), 0x10), 8));
setBackgroundColor(ColorUtils.blendARGB(ThemesRepo.getColor(R.attr.snackbarBase), ThemesRepo.getColor(R.attr.snackbarError), 0.15f));
break;
}
@@ -288,6 +315,9 @@ public class SnackbarsLayout extends FrameLayout {
public int lifetime = 2500;
public String tag;
public CharSequence buttonTitle;
public View.OnClickListener buttonClick;
public Snackbar(Type type, CharSequence title) {
this.type = type;
this.title = title;
+4 -1
View File
@@ -28,6 +28,7 @@
<string name="MenuFileAIGeneratorError">Не удалось сгенерировать модель</string>
<string name="MenuFileAIGeneratorSavedAs">Модель сохранена как %s.</string>
<string name="MenuFileAIGeneratorNoGenerationsLeft">Не осталось генераций.</string>
<string name="MenuFileAIGeneratorAlreadyGenerating">Уже генерируется другая модель.</string>
<string name="MenuFileCalibrations">Калибров.</string>
<string name="MenuFileCalibrationsLA">K3D Linear Advance</string>
<string name="MenuFileCalibrationsLADescription">Калибровка Linear/Pressure Advance</string>
@@ -173,12 +174,14 @@
<string name="SettingsCloudTapToShowMore">Нажмите чтобы узнать больше</string>
<string name="SettingsCloudManageTitle">Аккаунт Beam 3D</string>
<string name="SettingsCloudManageDescription">Даёт следующие преимущества:</string>
<string name="SettingsCloudManageFeatureEarlyAccess">Ранний доступ</string>
<string name="SettingsCloudManageFeatureEarlyAccessDescription">Вы можете скачать ранние сборки из чата Telegram для подписчиков</string>
<string name="SettingsCloudManageFeatureCloudSync">Облачная синхронизация профилей</string>
<string name="SettingsCloudManageFeatureCloudSyncDescription">Храните свои профили в облаке Beam</string>
<string name="SettingsCloudManageFeatureAIGenerator">ИИ генератор моделей</string>
<string name="SettingsCloudManageFeatureAIGeneratorDescription">%1$d моделей по фото в месяц</string>
<string name="SettingsCloudManageFeatureFreeForAll">Slice Beam может оставаться бесплатным для всех</string>
<string name="SettingsCloudManageFeatureFreeForAllDescription">Спасибо за вашу поддержку!</string>
<string name="SettingsCloudManageFeatureFreeForAllDescription">Ваш ник будет написан в списке поддержавших.\nСпасибо за вашу поддержку!</string>
<string name="SettingsCloudManageLevelRedirectMessage">При подписке на данный уровень вы соглашаетесь с условиями обслуживания.</string>
<string name="SettingsCloudManageLevelRedirectAlreadySubscribed">Уже подписаны?</string>
<string name="SettingsCloudManageFree">Бесплатно</string>
+4 -1
View File
@@ -29,6 +29,7 @@
<string name="MenuFileAIGeneratorError">Failed to generate model</string>
<string name="MenuFileAIGeneratorSavedAs">Saved model as %s.</string>
<string name="MenuFileAIGeneratorNoGenerationsLeft">No generations left.</string>
<string name="MenuFileAIGeneratorAlreadyGenerating">Already generating another model.</string>
<string name="MenuFileCalibrations">Calibrat.</string>
<string name="MenuFileCalibrationsLA">K3D Linear Advance</string>
<string name="MenuFileCalibrationsLADescription">Linear/Pressure Advance Calibration</string>
@@ -175,12 +176,14 @@
<string name="SettingsCloudTapToShowMore">Tap to learn more</string>
<string name="SettingsCloudManageTitle">Beam 3D Account</string>
<string name="SettingsCloudManageDescription">Provides the following benefits:</string>
<string name="SettingsCloudManageFeatureEarlyAccess">Early access</string>
<string name="SettingsCloudManageFeatureEarlyAccessDescription">You can download early builds from Telegram chat for subscribers</string>
<string name="SettingsCloudManageFeatureCloudSync">Cloud profiles sync</string>
<string name="SettingsCloudManageFeatureCloudSyncDescription">Store your profiles in Beam Cloud</string>
<string name="SettingsCloudManageFeatureAIGenerator">AI model generator</string>
<string name="SettingsCloudManageFeatureAIGeneratorDescription">%1$d models from photo per month</string>
<string name="SettingsCloudManageFeatureFreeForAll">Slice Beam can remain free for all</string>
<string name="SettingsCloudManageFeatureFreeForAllDescription">Thanks for your support!</string>
<string name="SettingsCloudManageFeatureFreeForAllDescription">Your nickname will be written in the list of supporters.\nThanks for your support!</string>
<string name="SettingsCloudManageLevelRedirectMessage">By subscribing to this level you accept terms of service.</string>
<string name="SettingsCloudManageLevelRedirectAlreadySubscribed">Already subscribed?</string>
<string name="SettingsCloudManageFree">Free</string>