diff --git a/gleap/build.gradle b/gleap/build.gradle index d485cee..6d73540 100644 --- a/gleap/build.gradle +++ b/gleap/build.gradle @@ -28,7 +28,7 @@ android { defaultConfig { minSdkVersion 21 targetSdkVersion 33 - buildConfigField "String", "VERSION_NAME", "\"16.4.2\"" + buildConfigField "String", "VERSION_NAME", "\"16.4.5\"" testInstrumentationRunner "android.support.test.runner.AndroidJUnitRunner" } diff --git a/gleap/src/main/java/io/gleap/GleapChatMessage.java b/gleap/src/main/java/io/gleap/GleapChatMessage.java index ca98112..5128b28 100644 --- a/gleap/src/main/java/io/gleap/GleapChatMessage.java +++ b/gleap/src/main/java/io/gleap/GleapChatMessage.java @@ -6,11 +6,14 @@ import android.content.res.Resources; import android.graphics.Bitmap; import android.graphics.Color; +import android.graphics.Outline; import android.graphics.Typeface; import android.graphics.drawable.GradientDrawable; +import android.os.Build; import android.text.TextUtils; import android.view.Gravity; import android.view.View; +import android.view.ViewOutlineProvider; import android.widget.ImageView; import android.widget.LinearLayout; import android.widget.TextView; @@ -19,8 +22,6 @@ import org.json.JSONObject; -import gleap.io.gleap.R; - class GleapChatMessage { private String outboundId; private String type = "comment"; @@ -33,11 +34,13 @@ class GleapChatMessage { private int currentStep; private int totalSteps; private String nextStepTitle; + private String sendAt; + private String createdAt; private Bitmap avatarBitmap = null; private Bitmap topImageBitmap = null; private LinearLayout layout; - public GleapChatMessage(String outboundId, String type, String text, String shareToken, GleapSender sender, String newsId, String image, int currentStep, int totalSteps, String nextStepTitle, String checklistId) { + public GleapChatMessage(String outboundId, String type, String text, String shareToken, GleapSender sender, String newsId, String image, int currentStep, int totalSteps, String nextStepTitle, String checklistId, String sendAt, String createdAt) { this.outboundId = outboundId; this.sender = sender; this.type = type; @@ -49,6 +52,8 @@ public GleapChatMessage(String outboundId, String type, String text, String shar this.totalSteps = totalSteps; this.nextStepTitle = nextStepTitle; this.checklistId = checklistId; + this.sendAt = sendAt; + this.createdAt = createdAt; } private void generateComponent(Activity activity) { @@ -98,110 +103,88 @@ public void clearComponent() { this.layout = null; } - public LinearLayout getNews(Activity local) { - Activity activity = ActivityUtil.getCurrentActivity(); - LinearLayout completeMessage = new LinearLayout(local); - completeMessage.setId(View.generateViewId()); - completeMessage.setOrientation(LinearLayout.VERTICAL); - completeMessage.setVisibility(View.GONE); - - ImageView topImage = new ImageView(local); - topImage.setMaxHeight(convertDpToPixel(155, activity)); - topImage.setMinimumHeight(convertDpToPixel(155, activity)); - topImage.setAdjustViewBounds(true); - topImage.setScaleType(ImageView.ScaleType.CENTER_CROP); - new GleapImageHandler(image, topImage, new GleapImageLoaded() { - @Override - public void invoke(Bitmap bitmap) { - topImageBitmap = bitmap; - completeMessage.setVisibility(View.VISIBLE); - GleapInvisibleActivityManger.getInstance().updateCloseButtonState(); - } - }).execute(); + /** + * Every notification card shares one chrome: full stack width, the + * project's container radius, a hairline border and a soft shadow, on the + * widget theme's background color. The card clips its children to the + * rounded outline, so e.g. a news cover squares off against the corners. + */ + private CardView styledCard(Activity local, View content) { + CardView cardView = new CardView(local); + int containerRadius = GleapNotificationStyle.containerRadiusPx(local); + cardView.setRadius(containerRadius); + // Soft and airy, matching the iOS SDK's two-layer look — a low + // elevation, further lightened where the platform allows tinting. + cardView.setCardElevation(convertDpToPixel(3, local)); + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) { + cardView.setOutlineSpotShadowColor(Color.argb(115, 0, 0, 0)); + } + cardView.setCardBackgroundColor(GleapNotificationStyle.backgroundColor()); + cardView.setClipToOutline(true); + cardView.setUseCompatPadding(false); + + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { + GradientDrawable hairline = new GradientDrawable(); + hairline.setShape(GradientDrawable.RECTANGLE); + hairline.setColor(Color.TRANSPARENT); + hairline.setCornerRadius(containerRadius); + hairline.setStroke(convertDpToPixel(1, local), GleapNotificationStyle.hairlineColor()); + cardView.setForeground(hairline); + } - completeMessage.addView(topImage); + cardView.addView(content, new CardView.LayoutParams(CardView.LayoutParams.MATCH_PARENT, CardView.LayoutParams.WRAP_CONTENT)); + return cardView; + } - LinearLayout bottomPart = new LinearLayout(local); - bottomPart.setOrientation(LinearLayout.VERTICAL); + // Sender avatar with the shape split the messenger makes: teammates stay + // circular, the bot gets a rounded square. `isBot` is absent on payloads + // from servers that don't send it yet, which falls through to the circle. + private ImageView avatarImageView(Activity local, int sizeDp) { ImageView avatarImage = new ImageView(local); - avatarImage.setMaxHeight(convertDpToPixel(24, activity)); - avatarImage.setMinimumHeight(convertDpToPixel(24, activity)); - avatarImage.setMinimumWidth(convertDpToPixel(24, activity)); - avatarImage.setMaxWidth(convertDpToPixel(24, activity)); - avatarImage.setAdjustViewBounds(true); avatarImage.setScaleType(ImageView.ScaleType.CENTER_CROP); + avatarImage.setBackgroundColor(GleapNotificationStyle.shadeOfColor(GleapNotificationStyle.backgroundColor(), GleapNotificationStyle.isDarkTheme() ? 30 : -12)); - LinearLayout.LayoutParams avatarParams = new LinearLayout.LayoutParams(LinearLayout.LayoutParams.WRAP_CONTENT, LinearLayout.LayoutParams.WRAP_CONTENT); - avatarImage.setLayoutParams(avatarParams); - - if (avatarBitmap == null) { - new GleapRoundImageHandler(getSender().getProfileImageUrl(), avatarImage, new GleapImageLoaded() { - @Override - public void invoke(Bitmap bitmap) { - avatarBitmap = bitmap; - GleapInvisibleActivityManger.animateViewInOut(completeMessage, true); - } - }).execute(); - } else { - avatarImage.setImageBitmap(this.avatarBitmap); - completeMessage.setVisibility(View.VISIBLE); - } + final int radiusPx = sender != null && sender.isBot() + ? GleapNotificationStyle.botAvatarRadiusPx(local, sizeDp) + : convertDpToPixel(sizeDp, local) / 2; + avatarImage.setOutlineProvider(new ViewOutlineProvider() { + @Override + public void getOutline(View view, Outline outline) { + outline.setRoundRect(0, 0, view.getWidth(), view.getHeight(), radiusPx); + } + }); + avatarImage.setClipToOutline(true); - float width = (float) (getScreenWidth() * 0.8); - if (width > convertDpToPixel(280, activity)) { - width = convertDpToPixel(280, activity); - } + new GleapImageHandler(getSender().getProfileImageUrl(), avatarImage, new GleapImageLoaded() { + @Override + public void invoke(Bitmap bitmap) { + avatarBitmap = bitmap; + } + }).execute(); - TextView titleComponent = new TextView(local); - titleComponent.setId(View.generateViewId()); - titleComponent.setText(getText().replace("{{name}}", getName())); - titleComponent.setTextSize(16); - titleComponent.setTextColor(Color.BLACK); - titleComponent.setSingleLine(); - titleComponent.setMaxWidth((int) width); - titleComponent.setWidth((int) width); - titleComponent.setEllipsize(TextUtils.TruncateAt.END); - titleComponent.setTypeface(Typeface.DEFAULT_BOLD); - titleComponent.setTextColor(Color.BLACK); - titleComponent.setPadding(convertDpToPixel(0, local), convertDpToPixel(0, local), convertDpToPixel(10, local), convertDpToPixel(0, local)); - bottomPart.addView(titleComponent); + return avatarImage; + } - TextView usernameTextView = new TextView(local); - usernameTextView.setId(View.generateViewId()); - usernameTextView.setText(getSender().getName()); - usernameTextView.setTextColor(Color.GRAY); - usernameTextView.setTextSize(14); - - LinearLayout.LayoutParams messageComponentParams = new LinearLayout.LayoutParams(LinearLayout.LayoutParams.WRAP_CONTENT, LinearLayout.LayoutParams.WRAP_CONTENT); - messageComponentParams.setMargins(convertDpToPixel(10, local), convertDpToPixel(0, local), convertDpToPixel(0, local), convertDpToPixel(0, local)); - usernameTextView.setLayoutParams(messageComponentParams); - - LinearLayout userLayout = new LinearLayout(local); - userLayout.addView(avatarImage, convertDpToPixel(24, local), convertDpToPixel(24, local)); - userLayout.addView(usernameTextView); - userLayout.setGravity(Gravity.CENTER_VERTICAL); - userLayout.setPadding(0, convertDpToPixel(5, activity), 0, 0); - - LinearLayout.LayoutParams bottomParams = new LinearLayout.LayoutParams(LinearLayout.LayoutParams.WRAP_CONTENT, LinearLayout.LayoutParams.WRAP_CONTENT); - bottomParams.setMargins(convertDpToPixel(17, local), convertDpToPixel(13, local), convertDpToPixel(17, local), convertDpToPixel(13, local)); - bottomPart.setLayoutParams(bottomParams); - bottomPart.addView(userLayout); - completeMessage.addView(bottomPart); - completeMessage.setBackgroundColor(Color.WHITE); - - completeMessage.setOnClickListener(new View.OnClickListener() { + private View.OnClickListener cardClickListener() { + return new View.OnClickListener() { @Override public void onClick(View v) { + // A collapsed stack expands on the first tap instead of + // activating the card — same as the web widget on touch devices. + if (GleapInvisibleActivityManger.getInstance().maybeExpandStackOnTap()) { + return; + } + try { - if (!shareToken.equals("")) { + if (shareToken != null && !shareToken.equals("")) { JSONObject message = new JSONObject(); message.put("shareToken", getShareToken()); GleapConfig.getInstance().addGleapWebViewMessage(new GleapWebViewMessage("open-conversation", message)); - } else if (!newsId.equals("")) { + } else if (newsId != null && !newsId.equals("")) { JSONObject message = new JSONObject(); message.put("id", getNewsId()); GleapConfig.getInstance().addGleapWebViewMessage(new GleapWebViewMessage("open-news-article", message)); - } else if (!checklistId.equals("")) { + } else if (checklistId != null && !checklistId.equals("")) { JSONObject message = new JSONObject(); message.put("id", getChecklistId()); GleapConfig.getInstance().addGleapWebViewMessage(new GleapWebViewMessage("open-checklist", message)); @@ -212,64 +195,109 @@ public void onClick(View v) { GleapInvisibleActivityManger.getInstance().clearMessages(); } - }); - - layout = completeMessage; - return completeMessage; + }; } - private GradientDrawable createRoundedRectangleDrawable(int color, float topLeft, float topRight, float bottomRight, float bottomLeft) { - GradientDrawable gradientDrawable = new GradientDrawable(); - gradientDrawable.setShape(GradientDrawable.RECTANGLE); - gradientDrawable.setColor(color); - gradientDrawable.setCornerRadii(new float[]{ - topLeft, topLeft, // Top-left radius - topRight, topRight, // Top-right radius - bottomRight, bottomRight, // Bottom-right radius - bottomLeft, bottomLeft // Bottom-left radius - }); - return gradientDrawable; - } + public LinearLayout getNews(Activity local) { + int contrastColor = GleapNotificationStyle.contrastColor(); + int subTextColor = GleapNotificationStyle.subTextColor(); + int contentPadding = convertDpToPixel(16, local); - public LinearLayout getChecklistCard(Activity local) { - Activity activity = ActivityUtil.getCurrentActivity(); + LinearLayout cardContent = new LinearLayout(local); + cardContent.setOrientation(LinearLayout.VERTICAL); - float width = (float) (getScreenWidth() * 0.8); - if (width > convertDpToPixel(280, activity)) { - width = convertDpToPixel(280, activity); + // The cover image squares off against the card's rounded top corners + // through the card's outline clip. + ImageView topImage = new ImageView(local); + topImage.setScaleType(ImageView.ScaleType.CENTER_CROP); + topImage.setBackgroundColor(GleapNotificationStyle.shadeOfColor(GleapNotificationStyle.backgroundColor(), GleapNotificationStyle.isDarkTheme() ? 30 : -12)); + cardContent.addView(topImage, new LinearLayout.LayoutParams(LinearLayout.LayoutParams.MATCH_PARENT, convertDpToPixel(155, local))); + new GleapImageHandler(image, topImage, new GleapImageLoaded() { + @Override + public void invoke(Bitmap bitmap) { + topImageBitmap = bitmap; + } + }).execute(); + + LinearLayout bottomPart = new LinearLayout(local); + bottomPart.setOrientation(LinearLayout.VERTICAL); + bottomPart.setPadding(contentPadding, contentPadding, contentPadding, contentPadding); + + TextView titleComponent = new TextView(local); + titleComponent.setId(View.generateViewId()); + titleComponent.setText(getText().replace("{{name}}", getName())); + titleComponent.setTextSize(15); + titleComponent.setTextColor(contrastColor); + titleComponent.setSingleLine(); + titleComponent.setEllipsize(TextUtils.TruncateAt.END); + titleComponent.setTypeface(Typeface.create("sans-serif-medium", Typeface.NORMAL)); + bottomPart.addView(titleComponent); + + boolean hasSender = getSender() != null && getSender().getName() != null && !getSender().getName().equals(""); + if (hasSender) { + LinearLayout userLayout = new LinearLayout(local); + userLayout.setOrientation(LinearLayout.HORIZONTAL); + userLayout.setGravity(Gravity.CENTER_VERTICAL); + + boolean hasAvatar = getSender().getProfileImageUrl() != null && !getSender().getProfileImageUrl().equals(""); + if (hasAvatar) { + ImageView avatarImage = avatarImageView(local, 20); + LinearLayout.LayoutParams avatarParams = new LinearLayout.LayoutParams(convertDpToPixel(20, local), convertDpToPixel(20, local)); + avatarParams.setMarginEnd(convertDpToPixel(8, local)); + userLayout.addView(avatarImage, avatarParams); + } + + TextView usernameTextView = new TextView(local); + usernameTextView.setId(View.generateViewId()); + usernameTextView.setText(getSender().getName()); + usernameTextView.setTextColor(subTextColor); + usernameTextView.setTextSize(14); + usernameTextView.setSingleLine(); + usernameTextView.setEllipsize(TextUtils.TruncateAt.END); + userLayout.addView(usernameTextView); + + LinearLayout.LayoutParams userParams = new LinearLayout.LayoutParams(LinearLayout.LayoutParams.WRAP_CONTENT, LinearLayout.LayoutParams.WRAP_CONTENT); + userParams.setMargins(0, convertDpToPixel(6, local), 0, 0); + bottomPart.addView(userLayout, userParams); } + cardContent.addView(bottomPart, new LinearLayout.LayoutParams(LinearLayout.LayoutParams.MATCH_PARENT, LinearLayout.LayoutParams.WRAP_CONTENT)); + + CardView cardView = styledCard(local, cardContent); + LinearLayout completeMessage = new LinearLayout(local); completeMessage.setId(View.generateViewId()); completeMessage.setOrientation(LinearLayout.VERTICAL); - completeMessage.setVisibility(View.VISIBLE); - completeMessage.setBackgroundColor(Color.WHITE); - completeMessage.setMinimumWidth((int) width); - LinearLayout.LayoutParams mainParams = new LinearLayout.LayoutParams( - LinearLayout.LayoutParams.MATCH_PARENT, - LinearLayout.LayoutParams.WRAP_CONTENT - ); - completeMessage.setPadding(convertDpToPixel(16, local), convertDpToPixel(12, local), convertDpToPixel(16, local), convertDpToPixel(12, local)); - completeMessage.setLayoutParams(mainParams); + completeMessage.addView(cardView, new LinearLayout.LayoutParams(LinearLayout.LayoutParams.MATCH_PARENT, LinearLayout.LayoutParams.WRAP_CONTENT)); + completeMessage.setOnClickListener(cardClickListener()); + cardView.setOnClickListener(cardClickListener()); + + layout = completeMessage; + return completeMessage; + } + + public LinearLayout getChecklistCard(Activity local) { + int contrastColor = GleapNotificationStyle.contrastColor(); + int subTextColor = GleapNotificationStyle.subTextColor(); + int contentPadding = convertDpToPixel(16, local); + + LinearLayout cardContent = new LinearLayout(local); + cardContent.setOrientation(LinearLayout.VERTICAL); + cardContent.setPadding(contentPadding, contentPadding, contentPadding, contentPadding); TextView titleComponent = new TextView(local); titleComponent.setId(View.generateViewId()); titleComponent.setText(getText().replace("{{name}}", getName())); titleComponent.setTextSize(16); - titleComponent.setTextColor(Color.BLACK); + titleComponent.setTextColor(contrastColor); titleComponent.setSingleLine(); - titleComponent.setMaxWidth((int) width); - titleComponent.setWidth((int) width); - titleComponent.setMinWidth((int) width); titleComponent.setEllipsize(TextUtils.TruncateAt.END); - titleComponent.setTypeface(Typeface.DEFAULT_BOLD); - titleComponent.setTextColor(Color.BLACK); - titleComponent.setPadding(convertDpToPixel(0, local), convertDpToPixel(0, local), convertDpToPixel(10, local), convertDpToPixel(0, local)); - completeMessage.addView(titleComponent); + titleComponent.setTypeface(Typeface.create("sans-serif-medium", Typeface.NORMAL)); + cardContent.addView(titleComponent); float cornerRadius = convertDpToPixel(4, local); - float progress = (float)getCurrentStep() / (float)getTotalSteps(); + float progress = (float) getCurrentStep() / (float) getTotalSteps(); if (progress < 1.0) { progress += 0.04; } @@ -279,14 +307,15 @@ public LinearLayout getChecklistCard(Activity local) { LinearLayout.LayoutParams containerParams = new LinearLayout.LayoutParams(LinearLayout.LayoutParams.MATCH_PARENT, convertDpToPixel(8, local)); containerParams.setMargins(0, convertDpToPixel(12, local), 0, convertDpToPixel(12, local)); progressBarContainer.setLayoutParams(containerParams); - GradientDrawable progressBarBgDrawable = createRoundedRectangleDrawable(Color.parseColor("#EEEEEE"), cornerRadius, cornerRadius, cornerRadius, cornerRadius); + int progressTrackColor = Color.argb(38, Color.red(contrastColor), Color.green(contrastColor), Color.blue(contrastColor)); + GradientDrawable progressBarBgDrawable = createRoundedRectangleDrawable(progressTrackColor, cornerRadius, cornerRadius, cornerRadius, cornerRadius); progressBarContainer.setBackground(progressBarBgDrawable); - progressBarContainer.setOrientation(LinearLayout.HORIZONTAL); // Horizontal orientation - completeMessage.addView(progressBarContainer); + progressBarContainer.setOrientation(LinearLayout.HORIZONTAL); + cardContent.addView(progressBarContainer); // Progress Bar View progressBar = new View(local); - LinearLayout.LayoutParams barParams = new LinearLayout.LayoutParams(0, LinearLayout.LayoutParams.MATCH_PARENT, progress); // weight = progress + LinearLayout.LayoutParams barParams = new LinearLayout.LayoutParams(0, LinearLayout.LayoutParams.MATCH_PARENT, progress); progressBar.setLayoutParams(barParams); GradientDrawable progressBarDrawable = createRoundedRectangleDrawable(Color.parseColor(GleapConfig.getInstance().getColor()), cornerRadius, cornerRadius, cornerRadius, cornerRadius); progressBar.setBackground(progressBarDrawable); @@ -294,148 +323,155 @@ public LinearLayout getChecklistCard(Activity local) { // Progress Bar Background View View progressBarBg = new View(local); - LinearLayout.LayoutParams bgParams = new LinearLayout.LayoutParams(0, LinearLayout.LayoutParams.MATCH_PARENT, 1-progress); // weight = 1-progress + LinearLayout.LayoutParams bgParams = new LinearLayout.LayoutParams(0, LinearLayout.LayoutParams.MATCH_PARENT, 1 - progress); progressBarBg.setLayoutParams(bgParams); progressBarContainer.addView(progressBarBg); TextView nextStepComponent = new TextView(local); nextStepComponent.setId(View.generateViewId()); nextStepComponent.setText(getNextStepTitle().replace("{{name}}", getName())); - nextStepComponent.setTextColor(Color.DKGRAY); - nextStepComponent.setTextSize(15); + nextStepComponent.setTextColor(subTextColor); + nextStepComponent.setTextSize(14); LinearLayout.LayoutParams messageParams = new LinearLayout.LayoutParams( LinearLayout.LayoutParams.WRAP_CONTENT, LinearLayout.LayoutParams.WRAP_CONTENT ); nextStepComponent.setLayoutParams(messageParams); - completeMessage.addView(nextStepComponent); + cardContent.addView(nextStepComponent); - completeMessage.setOnClickListener(new View.OnClickListener() { - @Override - public void onClick(View v) { - try { - if (!checklistId.equals("")) { - JSONObject message = new JSONObject(); - message.put("id", getChecklistId()); - GleapConfig.getInstance().addGleapWebViewMessage(new GleapWebViewMessage("open-checklist", message)); - } - Gleap.getInstance().open(); - } catch (Exception ex) { - } + CardView cardView = styledCard(local, cardContent); - GleapInvisibleActivityManger.getInstance().clearMessages(); - } - }); + LinearLayout completeMessage = new LinearLayout(local); + completeMessage.setId(View.generateViewId()); + completeMessage.setOrientation(LinearLayout.VERTICAL); + completeMessage.addView(cardView, new LinearLayout.LayoutParams(LinearLayout.LayoutParams.MATCH_PARENT, LinearLayout.LayoutParams.WRAP_CONTENT)); + completeMessage.setOnClickListener(cardClickListener()); + cardView.setOnClickListener(cardClickListener()); layout = completeMessage; return completeMessage; } + // Standard non-news notification. Avatar and text live inside one card (no + // speech-bubble tail), with the sender + time as a meta line below the + // message. public LinearLayout getPlainMessage(Activity local) { - LinearLayout messageContainer = new LinearLayout(local); - messageContainer.setVisibility(View.GONE); + int contrastColor = GleapNotificationStyle.contrastColor(); + int subTextColor = GleapNotificationStyle.subTextColor(); + int contentPadding = convertDpToPixel(16, local); + int avatarSizeDp = 32; + + LinearLayout cardContent = new LinearLayout(local); + cardContent.setOrientation(LinearLayout.HORIZONTAL); + cardContent.setBaselineAligned(false); + cardContent.setPadding(contentPadding, contentPadding, contentPadding, contentPadding); + + boolean hasAvatar = getSender() != null && getSender().getProfileImageUrl() != null && !getSender().getProfileImageUrl().equals(""); + if (hasAvatar) { + ImageView avatarImage = avatarImageView(local, avatarSizeDp); + LinearLayout.LayoutParams avatarParams = new LinearLayout.LayoutParams(convertDpToPixel(avatarSizeDp, local), convertDpToPixel(avatarSizeDp, local)); + avatarParams.setMarginEnd(convertDpToPixel(10, local)); + cardContent.addView(avatarImage, avatarParams); + } - TextView titleComponent = new TextView(local); - titleComponent.setId(View.generateViewId()); - titleComponent.setText(getSender().getName()); - titleComponent.setTextSize(14); - titleComponent.setTextColor(Color.GRAY); - LinearLayout.LayoutParams titleParams = new LinearLayout.LayoutParams( - LinearLayout.LayoutParams.WRAP_CONTENT, - LinearLayout.LayoutParams.WRAP_CONTENT - ); - titleParams.bottomMargin = convertDpToPixel(2, local); - titleComponent.setLayoutParams(titleParams); + LinearLayout body = new LinearLayout(local); + body.setOrientation(LinearLayout.VERTICAL); TextView messageComponent = new TextView(local); messageComponent.setId(View.generateViewId()); messageComponent.setText(getText().replace("{{name}}", getName())); - messageComponent.setTextColor(Color.BLACK); - messageComponent.setTextSize(16); - LinearLayout.LayoutParams messageParams = new LinearLayout.LayoutParams( - LinearLayout.LayoutParams.WRAP_CONTENT, - LinearLayout.LayoutParams.WRAP_CONTENT - ); - messageComponent.setLayoutParams(titleParams); - - LinearLayout completeMessage = new LinearLayout(local); - completeMessage.setOrientation(LinearLayout.VERTICAL); - completeMessage.addView(titleComponent); - completeMessage.addView(messageComponent); - completeMessage.setBackgroundResource(R.drawable.chatbubble); - completeMessage.setBaselineAligned(true); - completeMessage.setPadding(convertDpToPixel(17, local), convertDpToPixel(13, local), convertDpToPixel(17, local), convertDpToPixel(13, local)); - - CardView cardView = new CardView(local); - cardView.setBackgroundResource(R.drawable.rounded_corner); - LinearLayout.LayoutParams paramsBubble = new LinearLayout.LayoutParams(LinearLayout.LayoutParams.WRAP_CONTENT, LinearLayout.LayoutParams.WRAP_CONTENT); - paramsBubble.setMargins(convertDpToPixel(5, local), convertDpToPixel(9, local), convertDpToPixel(15, local), convertDpToPixel(4, local)); - cardView.setLayoutParams(paramsBubble); - - cardView.setElevation(4f); - cardView.addView(completeMessage); - - ImageView avatarImage = new ImageView(local); - - avatarImage.setMaxHeight(convertDpToPixel(28, local)); - avatarImage.setMinimumHeight(convertDpToPixel(28, local)); - avatarImage.setMinimumWidth(convertDpToPixel(28, local)); - avatarImage.setMaxWidth(convertDpToPixel(28, local)); - avatarImage.setAdjustViewBounds(true); - avatarImage.setScaleType(ImageView.ScaleType.CENTER_CROP); - - new GleapImageHandler(getSender().getProfileImageUrl(), avatarImage, new GleapImageLoaded() { - @Override - public void invoke(Bitmap bitmap) { - avatarBitmap = bitmap; - GleapInvisibleActivityManger.animateViewInOut(messageContainer, true); - GleapInvisibleActivityManger.getInstance().updateCloseButtonState(); + messageComponent.setTextColor(contrastColor); + messageComponent.setTextSize(15); + messageComponent.setMaxLines(2); + messageComponent.setEllipsize(TextUtils.TruncateAt.END); + body.addView(messageComponent, new LinearLayout.LayoutParams(LinearLayout.LayoutParams.MATCH_PARENT, LinearLayout.LayoutParams.WRAP_CONTENT)); + + // The "Sender · 5 minutes ago" line under the message. Either half may + // be missing, so the separator only appears when both are present. The + // sender may truncate; the timestamp never does. + String senderName = getSender() != null ? getSender().getName() : null; + boolean hasSenderName = senderName != null && !senderName.equals(""); + String timeLabel = GleapNotificationStyle.relativeTimeLabel(sendAt, createdAt); + if (hasSenderName || timeLabel != null) { + LinearLayout metaRow = new LinearLayout(local); + metaRow.setOrientation(LinearLayout.HORIZONTAL); + metaRow.setGravity(Gravity.CENTER_VERTICAL); + + TextView timeTextView = null; + float timeWidth = 0; + if (timeLabel != null) { + timeTextView = new TextView(local); + timeTextView.setText(timeLabel); + timeTextView.setTextSize(13); + timeTextView.setTextColor(subTextColor); + timeTextView.setSingleLine(); + timeWidth = timeTextView.getPaint().measureText(timeLabel); } - }).execute(); - - completeMessage.setOnClickListener(new View.OnClickListener() { - @Override - public void onClick(View v) { - try { - if (!shareToken.equals("")) { - JSONObject message = new JSONObject(); - message.put("shareToken", getShareToken()); - GleapConfig.getInstance().addGleapWebViewMessage(new GleapWebViewMessage("open-conversation", message)); - } else if (!newsId.equals("")) { - JSONObject message = new JSONObject(); - message.put("id", getNewsId()); - GleapConfig.getInstance().addGleapWebViewMessage(new GleapWebViewMessage("open-news-article", message)); - } - Gleap.getInstance().open(); - } catch (Exception ex) { + if (hasSenderName) { + TextView senderTextView = new TextView(local); + senderTextView.setText(senderName); + senderTextView.setTextSize(13); + senderTextView.setTextColor(subTextColor); + senderTextView.setTypeface(Typeface.create("sans-serif-medium", Typeface.NORMAL)); + senderTextView.setSingleLine(); + senderTextView.setEllipsize(TextUtils.TruncateAt.END); + + if (timeTextView != null) { + int dotSpace = convertDpToPixel(13, local); + int bodyWidth = GleapNotificationStyle.stackWidthPx(local) - (contentPadding * 2) - (hasAvatar ? convertDpToPixel(avatarSizeDp + 10, local) : 0); + senderTextView.setMaxWidth(Math.max(0, bodyWidth - dotSpace - (int) Math.ceil(timeWidth))); + } + metaRow.addView(senderTextView); + + if (timeTextView != null) { + TextView dotTextView = new TextView(local); + dotTextView.setText("•"); + dotTextView.setTextSize(13); + dotTextView.setTextColor(Color.argb(153, Color.red(subTextColor), Color.green(subTextColor), Color.blue(subTextColor))); + LinearLayout.LayoutParams dotParams = new LinearLayout.LayoutParams(LinearLayout.LayoutParams.WRAP_CONTENT, LinearLayout.LayoutParams.WRAP_CONTENT); + dotParams.setMarginStart(convertDpToPixel(5, local)); + dotParams.setMarginEnd(convertDpToPixel(5, local)); + metaRow.addView(dotTextView, dotParams); } + } - GleapInvisibleActivityManger.getInstance().clearMessages(); + if (timeTextView != null) { + metaRow.addView(timeTextView); } - }); - LinearLayout.LayoutParams params = new LinearLayout.LayoutParams(LinearLayout.LayoutParams.WRAP_CONTENT, LinearLayout.LayoutParams.WRAP_CONTENT); + LinearLayout.LayoutParams metaParams = new LinearLayout.LayoutParams(LinearLayout.LayoutParams.WRAP_CONTENT, LinearLayout.LayoutParams.WRAP_CONTENT); + metaParams.setMargins(0, convertDpToPixel(5, local), 0, 0); + body.addView(metaRow, metaParams); + } - CardView rounded = new CardView(local); - rounded.setRadius(convertDpToPixel(32, local) / 2); - rounded.addView(avatarImage, convertDpToPixel(28, local), convertDpToPixel(28, local)); + LinearLayout.LayoutParams bodyParams = new LinearLayout.LayoutParams(0, LinearLayout.LayoutParams.WRAP_CONTENT, 1); + cardContent.addView(body, bodyParams); - messageContainer.addView(rounded); - messageContainer.addView(cardView); - rounded.setElevation(4f); - LinearLayout.LayoutParams paramsAvatar = new LinearLayout.LayoutParams(LinearLayout.LayoutParams.WRAP_CONTENT, LinearLayout.LayoutParams.WRAP_CONTENT); - paramsAvatar.setMargins(convertDpToPixel(1, local), convertDpToPixel(10, local), convertDpToPixel(6, local), convertDpToPixel(4, local)); - rounded.setLayoutParams(paramsAvatar); + CardView cardView = styledCard(local, cardContent); - messageContainer.setGravity(Gravity.RIGHT | Gravity.BOTTOM); - messageContainer.setOrientation(LinearLayout.HORIZONTAL); - if (GleapConfig.getInstance().getWidgetPosition() != WidgetPosition.CLASSIC_LEFT && GleapConfig.getInstance().getWidgetPosition() != WidgetPosition.BOTTOM_LEFT) { - params.setMargins(convertDpToPixel(20, local), convertDpToPixel(0, local), convertDpToPixel(5, local), convertDpToPixel(4, local)); - } + LinearLayout completeMessage = new LinearLayout(local); + completeMessage.setId(View.generateViewId()); + completeMessage.setOrientation(LinearLayout.VERTICAL); + completeMessage.addView(cardView, new LinearLayout.LayoutParams(LinearLayout.LayoutParams.MATCH_PARENT, LinearLayout.LayoutParams.WRAP_CONTENT)); + completeMessage.setOnClickListener(cardClickListener()); + cardView.setOnClickListener(cardClickListener()); - messageContainer.setLayoutParams(params); - return messageContainer; + layout = completeMessage; + return completeMessage; + } + + private GradientDrawable createRoundedRectangleDrawable(int color, float topLeft, float topRight, float bottomRight, float bottomLeft) { + GradientDrawable gradientDrawable = new GradientDrawable(); + gradientDrawable.setShape(GradientDrawable.RECTANGLE); + gradientDrawable.setColor(color); + gradientDrawable.setCornerRadii(new float[]{ + topLeft, topLeft, // Top-left radius + topRight, topRight, // Top-right radius + bottomRight, bottomRight, // Bottom-right radius + bottomLeft, bottomLeft // Bottom-left radius + }); + return gradientDrawable; } private int getScreenWidth() { diff --git a/gleap/src/main/java/io/gleap/GleapConfig.java b/gleap/src/main/java/io/gleap/GleapConfig.java index 0411bca..6b9f86f 100644 --- a/gleap/src/main/java/io/gleap/GleapConfig.java +++ b/gleap/src/main/java/io/gleap/GleapConfig.java @@ -82,6 +82,7 @@ class GleapConfig { private String buttonColor = "#485bff"; private String color = "#485bff"; private String backgroundColor = "#ffffff"; + private int borderRadius = 20; private String headerColor = "#485bff"; // Loading-background config (mirrors the web/iOS SDK loaders). headerColor2/3 // fall back to headerColor via their getters, like the messenger's @@ -229,6 +230,13 @@ public void initConfig(JSONObject config) { } } + if (flowConfigs.has("borderRadius")) { + try { + this.borderRadius = flowConfigs.optInt("borderRadius", 20); + } catch (Exception ignore) { + } + } + if (flowConfigs.has("headerColor")) { this.headerColor = flowConfigs.getString("headerColor"); } @@ -643,6 +651,10 @@ public String getBackgroundColor() { return backgroundColor; } + public int getBorderRadius() { + return borderRadius; + } + public String getHeaderColor() { return headerColor; } diff --git a/gleap/src/main/java/io/gleap/GleapEventService.java b/gleap/src/main/java/io/gleap/GleapEventService.java index bbc4b16..d3b5a68 100644 --- a/gleap/src/main/java/io/gleap/GleapEventService.java +++ b/gleap/src/main/java/io/gleap/GleapEventService.java @@ -201,7 +201,7 @@ private JSONArray arrayToJSONArray(List arrayList) { return result; } - private GleapChatMessage createComment(String outboundId, JSONObject messageData) throws Exception { + private GleapChatMessage createComment(String outboundId, JSONObject messageData, String sendAt, String createdAt) throws Exception { String senderName = ""; String profileImageUrl = ""; String text = ""; @@ -213,6 +213,7 @@ private GleapChatMessage createComment(String outboundId, JSONObject messageData String nextStepTitle = ""; int currentStep = 0; int totalSteps = 0; + boolean senderIsBot = false; if (messageData.has("type")) { type = messageData.getString("type"); @@ -232,6 +233,10 @@ private GleapChatMessage createComment(String outboundId, JSONObject messageData if (sender.has("profileImageUrl")) { profileImageUrl = sender.getString("profileImageUrl"); } + + if (sender.has("isBot")) { + senderIsBot = sender.optBoolean("isBot", false); + } } if (messageData.has("conversation")) { @@ -271,9 +276,9 @@ private GleapChatMessage createComment(String outboundId, JSONObject messageData nextStepTitle = messageData.getString("nextStepTitle"); } - GleapSender sender = new GleapSender(senderName, profileImageUrl); + GleapSender sender = new GleapSender(senderName, profileImageUrl, senderIsBot); return new GleapChatMessage(outboundId, type, text, shareToken, sender, newsId, coverImageUrl, currentStep, - totalSteps, nextStepTitle, checklistId); + totalSteps, nextStepTitle, checklistId, sendAt, createdAt); } public void processEventData(JSONObject data) throws Exception { @@ -336,7 +341,7 @@ public void run() { outboundId = currentAction.getString("outbound"); } JSONObject data = currentAction.getJSONObject("data"); - GleapChatMessage comment = createComment(outboundId, data); + GleapChatMessage comment = createComment(outboundId, data, currentAction.optString("sendAt", ""), currentAction.optString("createdAt", "")); GleapInvisibleActivityManger.getInstance().addNotification(comment, null); } catch (JSONException e) { diff --git a/gleap/src/main/java/io/gleap/GleapInvisibleActivityManger.java b/gleap/src/main/java/io/gleap/GleapInvisibleActivityManger.java index 946f15f..aa4fb93 100644 --- a/gleap/src/main/java/io/gleap/GleapInvisibleActivityManger.java +++ b/gleap/src/main/java/io/gleap/GleapInvisibleActivityManger.java @@ -5,9 +5,11 @@ import android.animation.Animator; import android.animation.AnimatorListenerAdapter; import android.animation.ObjectAnimator; +import android.animation.RectEvaluator; import android.app.Activity; import android.graphics.Bitmap; import android.graphics.Color; +import android.graphics.Rect; import android.graphics.Typeface; import android.graphics.drawable.GradientDrawable; import android.os.Build; @@ -18,7 +20,10 @@ import android.view.ViewGroup; import android.view.ViewParent; import android.view.WindowInsets; +import android.view.animation.DecelerateInterpolator; +import android.view.animation.PathInterpolator; import android.widget.Button; +import android.widget.FrameLayout; import android.widget.ImageButton; import android.widget.ImageView; import android.widget.LinearLayout; @@ -31,6 +36,7 @@ import org.json.JSONObject; +import java.util.ArrayList; import java.util.Iterator; import java.util.LinkedList; import java.util.List; @@ -47,10 +53,12 @@ class GleapInvisibleActivityManger { private ConstraintLayout layout; private TextView notificationCountTextView; private LinearLayout notificationContainerLayout; - private LinearLayout notificationListContainer; + private FrameLayout notificationStackFrame; private ImageButton imageButton; private Bitmap fabIcon; - private RelativeLayout closeButtonContainer; + private FrameLayout closeButtonContainer; + private boolean stackExpanded = false; + private View pendingEntranceView; private Button squareButton; private GleapBanner banner; private JSONObject bannerData; @@ -118,6 +126,90 @@ public void setVisible() { } } + /** + * (Re)applies the notification container's position constraints. Runs on + * creation and again whenever the feedback button's visibility flips — + * the container anchors to the button when it is shown, and the button's + * state can settle after the container was first built (the config + * applies asynchronously). Without the re-apply, notifications rendered + * in that window sat at the bottom of the screen until the next rebuild. + */ + private void applyNotificationContainerConstraints(Activity activity) { + try { + if (layout == null || notificationContainerLayout == null || activity == null) { + return; + } + + int offsetX = GleapConfig.getInstance().getButtonX(); + int offsetY = GleapConfig.getInstance().getButtonY(); + + ConstraintSet set = new ConstraintSet(); + set.clone(layout); + + // Reset both horizontal anchors — a re-apply may switch sides. + set.clear(notificationContainerLayout.getId(), ConstraintSet.START); + set.clear(notificationContainerLayout.getId(), ConstraintSet.END); + + // The container carries the stack frame's fixed height explicitly: + // left at WRAP_CONTENT, ConstraintLayout measures it AT_MOST the + // parent's height, the taller frame inside overflows past the + // container's bottom, and the cards render below the screen. + set.constrainHeight(notificationContainerLayout.getId(), GleapNotificationStyle.stackFrameHeightPx(activity)); + + int viewPadding = 20; + + boolean manualHidden = GleapConfig.getInstance().isHideFeedbackButton(); + boolean canShowFeedbackButton = showFab && !manualHidden; + + // Feedback button hidden - apply default constraints plus optional notification container offset. + if (feedbackButtonRelativeLayout == null || !canShowFeedbackButton) { + int containerOffsetX = GleapConfig.getInstance().getNotificationContainerOffsetX(); + int containerOffsetY = GleapConfig.getInstance().getNotificationContainerOffsetY(); + set.connect(notificationContainerLayout.getId(), ConstraintSet.BOTTOM, layout.getId(), ConstraintSet.BOTTOM, convertDpToPixel(20 + containerOffsetY, activity)); + set.connect(notificationContainerLayout.getId(), ConstraintSet.START, layout.getId(), ConstraintSet.START, convertDpToPixel(20 + containerOffsetX, activity)); + } else { + // Apply constraints based on feedback button type. + int containerOffsetX = GleapConfig.getInstance().getNotificationContainerOffsetX(); + int containerOffsetY = GleapConfig.getInstance().getNotificationContainerOffsetY(); + if (GleapConfig.getInstance().getWidgetPosition() == WidgetPosition.BOTTOM_LEFT) { + set.connect(notificationContainerLayout.getId(), ConstraintSet.BOTTOM, feedbackButtonRelativeLayout.getId(), ConstraintSet.TOP, convertDpToPixel(15 + containerOffsetY, activity)); + set.connect(notificationContainerLayout.getId(), ConstraintSet.START, layout.getId(), ConstraintSet.START, convertDpToPixel(offsetX + containerOffsetX, activity)); + viewPadding = offsetX; + } else if (GleapConfig.getInstance().getWidgetPosition() == WidgetPosition.BOTTOM_RIGHT) { + set.connect(notificationContainerLayout.getId(), ConstraintSet.BOTTOM, feedbackButtonRelativeLayout.getId(), ConstraintSet.TOP, convertDpToPixel(15 + containerOffsetY, activity)); + set.connect(notificationContainerLayout.getId(), ConstraintSet.END, layout.getId(), ConstraintSet.END, convertDpToPixel(offsetX + containerOffsetX, activity)); + viewPadding = offsetX; + notificationContainerLayout.setGravity(Gravity.RIGHT); + } else if (GleapConfig.getInstance().getWidgetPosition() == WidgetPosition.CLASSIC_LEFT) { + set.connect(notificationContainerLayout.getId(), ConstraintSet.BOTTOM, layout.getId(), ConstraintSet.BOTTOM, convertDpToPixel(offsetY + containerOffsetY, activity)); + set.connect(notificationContainerLayout.getId(), ConstraintSet.START, layout.getId(), ConstraintSet.START, convertDpToPixel(offsetX + containerOffsetX, activity)); + } else if (GleapConfig.getInstance().getWidgetPosition() == WidgetPosition.CLASSIC_BOTTOM) { + set.connect(notificationContainerLayout.getId(), ConstraintSet.BOTTOM, feedbackButtonRelativeLayout.getId(), ConstraintSet.TOP, convertDpToPixel(15 + containerOffsetY, activity)); + set.connect(notificationContainerLayout.getId(), ConstraintSet.END, layout.getId(), ConstraintSet.END, convertDpToPixel(20 + containerOffsetX, activity)); + notificationContainerLayout.setGravity(Gravity.RIGHT); + } else { + set.connect(notificationContainerLayout.getId(), ConstraintSet.BOTTOM, layout.getId(), ConstraintSet.BOTTOM, convertDpToPixel(offsetY + containerOffsetY, activity)); + set.connect(notificationContainerLayout.getId(), ConstraintSet.END, layout.getId(), ConstraintSet.END, convertDpToPixel(20 + containerOffsetX, activity)); + notificationContainerLayout.setGravity(Gravity.RIGHT); + } + } + + // Set max width. + try { + DisplayMetrics displayMetrics = new DisplayMetrics(); + activity.getWindowManager().getDefaultDisplay().getMetrics(displayMetrics); + int deviceWidth = displayMetrics.widthPixels; + int deviceHeight = displayMetrics.heightPixels; + int smallerDimension = Math.min(deviceWidth, deviceHeight); + int maxWidthPx = smallerDimension - convertDpToPixel(viewPadding * 2, activity); + set.constrainMaxWidth(notificationContainerLayout.getId(), maxWidthPx); + } catch (Exception exp) {} + + set.applyTo(layout); + } catch (Exception exp) { + } + } + public void createNotificationLayout(Activity activity) { if (activity == null) { activity = ActivityUtil.getCurrentActivity(); @@ -142,111 +234,78 @@ public void run() { notificationContainerLayout.setOrientation(LinearLayout.VERTICAL); notificationContainerLayout.setGravity(Gravity.LEFT); - int offsetX = GleapConfig.getInstance().getButtonX(); - int offsetY = GleapConfig.getInstance().getButtonY(); - layout.addView(notificationContainerLayout); - ConstraintSet set = new ConstraintSet(); - set.clone(layout); - - int viewPadding = 20; - - boolean manualHidden = GleapConfig.getInstance().isHideFeedbackButton(); - boolean canShowFeedbackButton = showFab && !manualHidden; - - // Feedback button hidden - apply default constraints plus optional notification container offset. - if (feedbackButtonRelativeLayout == null || !canShowFeedbackButton) { - int containerOffsetX = GleapConfig.getInstance().getNotificationContainerOffsetX(); - int containerOffsetY = GleapConfig.getInstance().getNotificationContainerOffsetY(); - set.connect(notificationContainerLayout.getId(), ConstraintSet.BOTTOM, layout.getId(), ConstraintSet.BOTTOM, convertDpToPixel(20 + containerOffsetY, finalActivity)); - set.connect(notificationContainerLayout.getId(), ConstraintSet.START, layout.getId(), ConstraintSet.START, convertDpToPixel(containerOffsetX, finalActivity)); - } else { - // Apply constraints based on feedback button type. - int containerOffsetX = GleapConfig.getInstance().getNotificationContainerOffsetX(); - int containerOffsetY = GleapConfig.getInstance().getNotificationContainerOffsetY(); - if (GleapConfig.getInstance().getWidgetPosition() == WidgetPosition.BOTTOM_LEFT) { - set.connect(notificationContainerLayout.getId(), ConstraintSet.BOTTOM, feedbackButtonRelativeLayout.getId(), ConstraintSet.TOP, convertDpToPixel(15 + containerOffsetY, finalActivity)); - set.connect(notificationContainerLayout.getId(), ConstraintSet.START, layout.getId(), ConstraintSet.START, convertDpToPixel(offsetX + containerOffsetX, finalActivity)); - viewPadding = offsetX; - } else if (GleapConfig.getInstance().getWidgetPosition() == WidgetPosition.BOTTOM_RIGHT) { - set.connect(notificationContainerLayout.getId(), ConstraintSet.BOTTOM, feedbackButtonRelativeLayout.getId(), ConstraintSet.TOP, convertDpToPixel(15 + containerOffsetY, finalActivity)); - set.connect(notificationContainerLayout.getId(), ConstraintSet.END, layout.getId(), ConstraintSet.END, convertDpToPixel(offsetX - 20 + containerOffsetX, finalActivity)); - viewPadding = offsetX; - notificationContainerLayout.setGravity(Gravity.RIGHT); - } else if (GleapConfig.getInstance().getWidgetPosition() == WidgetPosition.CLASSIC_LEFT) { - set.connect(notificationContainerLayout.getId(), ConstraintSet.BOTTOM, layout.getId(), ConstraintSet.BOTTOM, convertDpToPixel(offsetY + containerOffsetY, finalActivity)); - set.connect(notificationContainerLayout.getId(), ConstraintSet.START, layout.getId(), ConstraintSet.START, convertDpToPixel(offsetX + containerOffsetX, finalActivity)); - } else if (GleapConfig.getInstance().getWidgetPosition() == WidgetPosition.CLASSIC_BOTTOM) { - set.connect(notificationContainerLayout.getId(), ConstraintSet.BOTTOM, feedbackButtonRelativeLayout.getId(), ConstraintSet.TOP, convertDpToPixel(15 + containerOffsetY, finalActivity)); - set.connect(notificationContainerLayout.getId(), ConstraintSet.END, layout.getId(), ConstraintSet.END, convertDpToPixel(containerOffsetX, finalActivity)); - notificationContainerLayout.setGravity(Gravity.RIGHT); - } else { - set.connect(notificationContainerLayout.getId(), ConstraintSet.BOTTOM, layout.getId(), ConstraintSet.BOTTOM, convertDpToPixel(offsetY + containerOffsetY, finalActivity)); - set.connect(notificationContainerLayout.getId(), ConstraintSet.END, layout.getId(), ConstraintSet.END, convertDpToPixel(containerOffsetX, finalActivity)); - notificationContainerLayout.setGravity(Gravity.RIGHT); - } + applyNotificationContainerConstraints(finalActivity); + + // The stack frame holds the cards (bottom-anchored, the + // newest in front) plus the floating close button. Nothing + // on this path may clip — peeking card edges, the close + // button overhang and the card shadows all draw outside + // their parents' bounds. + notificationContainerLayout.setClipChildren(false); + notificationContainerLayout.setClipToPadding(false); + layout.setClipChildren(false); + layout.setClipToPadding(false); + + if (notificationStackFrame == null) { + notificationStackFrame = new FrameLayout(finalActivity); + notificationStackFrame.setClipChildren(false); + notificationStackFrame.setClipToPadding(false); + + // The frame keeps one FIXED height, tall enough for any + // stack. Resizing it per arrival re-anchored the + // bottom-pinned cards mid-animation — the whole deck + // rendered offset by the height delta and visibly slid + // into place. With a constant height nothing ever + // re-bases; only the card animators move cards. The + // frame is transparent and not clickable, so the empty + // space above the cards stays inert. + int stackFrameHeight = GleapNotificationStyle.stackFrameHeightPx(finalActivity); + notificationContainerLayout.addView(notificationStackFrame, new LinearLayout.LayoutParams(GleapNotificationStyle.stackWidthPx(finalActivity), stackFrameHeight)); } - // Set max width. - try { - DisplayMetrics displayMetrics = new DisplayMetrics(); - finalActivity.getWindowManager().getDefaultDisplay().getMetrics(displayMetrics); - int deviceWidth = displayMetrics.widthPixels; - int deviceHeight = displayMetrics.heightPixels; - int smallerDimension = Math.min(deviceWidth, deviceHeight); - int maxWidthPx = smallerDimension - convertDpToPixel(viewPadding * 2, finalActivity); - set.constrainMaxWidth(notificationContainerLayout.getId(), maxWidthPx); - } catch (Exception exp) {} - - set.applyTo(layout); - - // Initialize close button. + // The close button floats over the stack's top corner + // instead of taking a row of its own above it. Its + // elevation keeps it above the cards' shadows. if (closeButtonContainer == null) { - ImageButton closeButton = new ImageButton(finalActivity); - - GradientDrawable gradientDrawable = new GradientDrawable(); - gradientDrawable.setCornerRadius(1000); - gradientDrawable.setColor(Color.parseColor("#878787")); - - closeButtonContainer = new RelativeLayout(finalActivity); - closeButtonContainer.setGravity(Gravity.RIGHT); + closeButtonContainer = new FrameLayout(finalActivity); + GradientDrawable closeBackground = new GradientDrawable(); + closeBackground.setShape(GradientDrawable.OVAL); + closeBackground.setColor(GleapNotificationStyle.backgroundColor()); + closeButtonContainer.setBackground(closeBackground); + // Above the cards' 4dp elevation, with the same + // softened shadow tint. + closeButtonContainer.setElevation(convertDpToPixel(6, finalActivity)); + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) { + closeButtonContainer.setOutlineSpotShadowColor(Color.argb(150, 0, 0, 0)); + } - LinearLayout.LayoutParams closeContainerParams = new LinearLayout.LayoutParams(LinearLayout.LayoutParams.MATCH_PARENT, LinearLayout.LayoutParams.MATCH_PARENT); - closeContainerParams.setMargins(convertDpToPixel(20, finalActivity), convertDpToPixel(0, finalActivity), convertDpToPixel(20, finalActivity), 0); - closeButtonContainer.setLayoutParams(closeContainerParams); + ImageView closeCross = new ImageView(finalActivity); + closeCross.setImageResource(R.drawable.close_white); + closeCross.setColorFilter(GleapNotificationStyle.contrastColor()); + int crossSize = convertDpToPixel(10, finalActivity); + closeButtonContainer.addView(closeCross, new FrameLayout.LayoutParams(crossSize, crossSize, Gravity.CENTER)); - closeButton.setBackgroundResource(R.drawable.close_white); - closeButton.setOnClickListener(new View.OnClickListener() { + closeButtonContainer.setOnClickListener(new View.OnClickListener() { @Override public void onClick(View v) { clearMessages(); } }); - RelativeLayout view = new RelativeLayout(finalActivity); - view.addView(closeButton, convertDpToPixel(18, finalActivity), convertDpToPixel(18, finalActivity)); - view.setBackground(gradientDrawable); - view.setPadding(15, 15, 15, 15); closeButtonContainer.setVisibility(View.GONE); - closeButtonContainer.addView(view); - notificationContainerLayout.addView(closeButtonContainer); + int closeSize = convertDpToPixel(26, finalActivity); + notificationStackFrame.addView(closeButtonContainer, new FrameLayout.LayoutParams(closeSize, closeSize, Gravity.TOP | Gravity.END)); } - if (notificationListContainer == null) { - notificationListContainer = new LinearLayout(finalActivity); - notificationListContainer.setOrientation(LinearLayout.VERTICAL); - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) { - notificationListContainer.setGravity(notificationContainerLayout.getGravity()); - } - notificationContainerLayout.addView(notificationListContainer); - } - - // Initially add all messages (if any) + // Initially add all messages (if any). Re-adds after a + // rebuild are not arrivals — no entrance animation. if (messages.size() > 0) { for (GleapChatMessage notification : messages) { - addNotificationViewToLayout(notification, finalActivity); + addNotificationViewToLayout(notification, finalActivity, false); } + updateCloseButtonState(); } } catch (Exception ex) { System.out.println(ex); @@ -257,22 +316,9 @@ public void onClick(View v) { public void removeNotificationViewFromLayout(GleapChatMessage notification) { try { - LinearLayout layout = notification.getComponent(null); - if (layout != null && layout.getParent() != null) { - if (layout.getParent() instanceof CardView) { - CardView parent = (CardView) layout.getParent(); - if (parent != null) { - parent.removeView(layout); - - LinearLayout grandParent = (LinearLayout) parent.getParent(); - if (grandParent != null) { - grandParent.removeView(parent); - } - } - } else if (layout.getParent() instanceof LinearLayout) { - LinearLayout parent = (LinearLayout) layout.getParent(); - parent.removeView(layout); - } + LinearLayout component = notification.getComponent(null); + if (component != null && component.getParent() instanceof ViewGroup) { + ((ViewGroup) component.getParent()).removeView(component); } } catch (Exception exp) { System.out.println(exp); @@ -288,11 +334,24 @@ public void removeNotificationViewFromLayout(GleapChatMessage notification) { this.messages.remove(notification); updateCloseButtonState(); + relayoutStack(false); } public void updateCloseButtonState() { if (closeButtonContainer != null) { if (this.messages.size() > 0) { + // Its elevation shadow ignores alpha and would pop in at full + // strength under the still-transparent button — ramp it with + // the fade. + if (closeButtonContainer.getVisibility() != View.VISIBLE) { + try { + float targetElevation = convertDpToPixel(6, ActivityUtil.getCurrentActivity()); + ObjectAnimator elevationAnimator = ObjectAnimator.ofFloat(closeButtonContainer, "elevation", 0f, targetElevation); + elevationAnimator.setDuration(200); + elevationAnimator.start(); + } catch (Exception exp) { + } + } animateViewInOut(closeButtonContainer, true); } else { closeButtonContainer.setVisibility(View.GONE); @@ -300,7 +359,7 @@ public void updateCloseButtonState() { } } - public void addNotificationViewToLayout(GleapChatMessage notification, Activity activity) { + public void addNotificationViewToLayout(GleapChatMessage notification, Activity activity, boolean isNewArrival) { if (activity == null) { activity = ActivityUtil.getCurrentActivity(); } @@ -309,29 +368,23 @@ public void addNotificationViewToLayout(GleapChatMessage notification, Activity return; } - if (notificationListContainer == null) { + if (notificationStackFrame == null) { return; } LinearLayout commentComponent = notification.getComponent(activity); - if (commentComponent != null) { - if (notification.getType().equals("news") || notification.getType().equals("checklist")) { - CardView cardView = new CardView(activity); - cardView.setBackgroundResource(R.drawable.rounded_corner); - LinearLayout.LayoutParams params = new LinearLayout.LayoutParams(LinearLayout.LayoutParams.WRAP_CONTENT, LinearLayout.LayoutParams.WRAP_CONTENT); - params.setMargins(convertDpToPixel(1, activity), convertDpToPixel(10, activity), convertDpToPixel(20, activity), convertDpToPixel(4, activity)); - cardView.setLayoutParams(params); - cardView.setElevation(4f); - if(commentComponent.getParent() == null) { - cardView.addView(commentComponent); - notificationListContainer.addView(cardView); - } - } else { - if (commentComponent.getParent() == null) { - notificationListContainer.addView(commentComponent); - } + if (commentComponent != null && commentComponent.getParent() == null) { + // Bottom-anchored: the stack math positions every card purely via + // translationY, and the add order keeps the newest card in front. + notificationStackFrame.addView(commentComponent, new FrameLayout.LayoutParams(FrameLayout.LayoutParams.MATCH_PARENT, FrameLayout.LayoutParams.WRAP_CONTENT, Gravity.BOTTOM)); + if (isNewArrival) { + // Invisible until the stack layout pass places it and starts + // the entrance — it must never flash at a resting position. + commentComponent.setAlpha(0f); + pendingEntranceView = commentComponent; } } + relayoutStack(isNewArrival); } public void destroyBanner(boolean clearData) { @@ -476,29 +529,35 @@ public void addNotification(GleapChatMessage comment, Activity activity) { } } - // Check notification limit. - GleapArrayHelper helper = new GleapArrayHelper<>(); - if (this.messages.size() >= 2) { - // Remove from layout. - GleapChatMessage notificationToRemove = this.messages.get(0); - removeNotificationViewFromLayout(notificationToRemove); - this.messages = helper.shiftArray(this.messages); + // More than one notification renders as a collapsed stack (newest in + // front), so a higher cap no longer costs vertical space. The oldest + // drop off beyond it. + while (this.messages.size() >= 4) { + removeNotificationViewFromLayout(this.messages.get(0)); } - // Make sure to only show one news or checklist notification at a time. If either is already in the list, remove it first. + // Make sure to only show one news or checklist notification at a time. If + // either is already in the list, remove it first. Collected up front: + // removeNotificationViewFromLayout mutates the message list, so it must + // not run inside an iteration over it. if (comment.getType().equals("news") || comment.getType().equals("checklist")) { - Iterator iterator = this.messages.iterator(); - while (iterator.hasNext()) { - GleapChatMessage message = iterator.next(); + List messagesToRemove = new ArrayList<>(); + for (GleapChatMessage message : this.messages) { if (message.getType().equals("news") || message.getType().equals("checklist")) { - removeNotificationViewFromLayout(message); - iterator.remove(); + messagesToRemove.add(message); } } + for (GleapChatMessage message : messagesToRemove) { + removeNotificationViewFromLayout(message); + } } + // A new arrival collapses the stack again. + this.stackExpanded = false; + this.messages.add(comment); - addNotificationViewToLayout(comment, activity); + addNotificationViewToLayout(comment, activity, true); + updateCloseButtonState(); } public void destoryLayout() { @@ -533,9 +592,9 @@ private void destroyNotificationLayout() { this.closeButtonContainer = null; } - if (this.notificationListContainer != null) { - this.notificationListContainer.removeAllViews(); - this.notificationListContainer = null; + if (this.notificationStackFrame != null) { + this.notificationStackFrame.removeAllViews(); + this.notificationStackFrame = null; } if (this.notificationContainerLayout != null) { @@ -711,11 +770,282 @@ void clearMessages() { // Clear message list. this.messages = new LinkedList<>(); + this.stackExpanded = false; }catch (Exception ex) { System.out.println(ex); } } + /** + * A collapsed stack expands on the first tap instead of activating the + * front card — same as the web widget on touch devices. Returns true when + * the tap was consumed by the expansion. + */ + boolean maybeExpandStackOnTap() { + if (this.messages.size() > 1 && !stackExpanded) { + stackExpanded = true; + applyStackLayout(null, true); + return true; + } + return false; + } + + // An elevation shadow is drawn from the view's outline and ignores the + // view's alpha — under a card fading in, the shadow would pop to full + // strength instantly (a short dark flicker before the card appears). + // Ramp the card's elevation from zero alongside the fade instead. + private void rampCardElevationWithFade(View cardRoot) { + try { + if (!(cardRoot instanceof ViewGroup)) { + return; + } + View inner = ((ViewGroup) cardRoot).getChildAt(0); + if (!(inner instanceof CardView)) { + return; + } + CardView cardView = (CardView) inner; + float targetElevation = cardView.getCardElevation(); + if (targetElevation <= 0f) { + return; + } + ObjectAnimator elevationAnimator = ObjectAnimator.ofFloat(cardView, "cardElevation", 0f, targetElevation); + elevationAnimator.setDuration(350); + elevationAnimator.setInterpolator(new PathInterpolator(0.4f, 0f, 0.2f, 1f)); + elevationAnimator.start(); + } catch (Exception exp) { + } + } + + private void relayoutStack(boolean withEntrance) { + if (notificationStackFrame == null) { + return; + } + + final View entranceView = withEntrance ? pendingEntranceView : null; + pendingEntranceView = null; + notificationStackFrame.post(new Runnable() { + @Override + public void run() { + applyStackLayout(entranceView, false); + } + }); + } + + /** + * Places every card for the current stack state. Cards are bottom-anchored + * in the stack frame: expanded they form a column with a fixed gap, + * collapsed the newest card sits in front with up to two older cards + * peeking out behind its top edge, scaled back like a deck. Anything + * deeper stays hidden until the stack expands. + * + * The frame always keeps the expanded height — collapsing only transforms + * the cards. The frame itself is not clickable, so the empty area above a + * collapsed stack stays transparent to touches. + */ + private void applyStackLayout(View entranceView, boolean animate) { + try { + if (notificationStackFrame == null) { + return; + } + + Activity activity = ActivityUtil.getCurrentActivity(); + if (activity == null) { + return; + } + + // Cards in visual order: oldest first, the newest last — the front + // card of the stack, and the bottom card of the expanded list. + List cards = new ArrayList<>(); + for (GleapChatMessage message : this.messages) { + LinearLayout component = message.getComponent(null); + if (component != null && component.getParent() == notificationStackFrame) { + cards.add(component); + } + } + + if (cards.isEmpty()) { + return; + } + + int gap = convertDpToPixel(12, activity); + int headroom = convertDpToPixel(17, activity); + int stackWidth = GleapNotificationStyle.stackWidthPx(activity); + + // Measure the heights — a just-added card has not been laid out yet. + int count = cards.size(); + int[] heights = new int[count]; + for (int i = 0; i < count; i++) { + View card = cards.get(i); + int height = card.getHeight(); + if (height <= 0) { + card.measure(View.MeasureSpec.makeMeasureSpec(stackWidth, View.MeasureSpec.EXACTLY), + View.MeasureSpec.makeMeasureSpec(0, View.MeasureSpec.UNSPECIFIED)); + height = card.getMeasuredHeight(); + } + heights[i] = height; + } + + int frontHeight = heights[count - 1]; + int expandedHeight = (count - 1) * gap; + for (int i = 0; i < count; i++) { + expandedHeight += heights[i]; + } + + int frameHeight = GleapNotificationStyle.stackFrameHeightPx(activity); + ViewGroup.LayoutParams frameParams = notificationStackFrame.getLayoutParams(); + if (frameParams != null && frameParams.height > 0) { + frameHeight = frameParams.height; + } + + boolean collapsed = count > 1 && !stackExpanded; + + // A new arrival on an existing stack is choreographed as one deck + // motion: the previous cards animate back into their tuck while + // the new card emerges from the stack's front slot — rather than + // the old front snapping back and the new card floating up from + // the empty space below the stack. + boolean arrival = entranceView != null && !animate && collapsed; + + int newerHeights = 0; + for (int i = count - 1; i >= 0; i--) { + View card = cards.get(i); + int depth = (count - 1) - i; + + float targetTy; + float targetScale; + float targetAlpha = 1f; + int overscan = convertDpToPixel(60, activity); + // Every card carries a clip at ALL times. The default opens + // generously past the body (a visual no-op — the elevation + // shadow is outline-based and ignores clipBounds entirely, so + // nothing is ever cut in a resting state). Collapsed cards + // behind the front clip to the front card's height in card + // space, like the web widget, and every transition ANIMATES + // the clip in lockstep with the card's motion — so a tall + // card's body can never poke out below the stack mid-flight. + Rect clip = new Rect(-overscan, -overscan, stackWidth + overscan, heights[i] + overscan); + + if (collapsed && depth > 0) { + // Tuck the card's top edge `peek`px above the front card's + // top; anything deeper than two peeks hides entirely. + int peek = convertDpToPixel(depth == 1 ? 9 : 17, activity); + targetScale = depth == 1 ? 0.955f : 0.91f; + targetTy = heights[i] - frontHeight - peek; + if (depth > 2) { + targetAlpha = 0f; + } + + if (heights[i] > frontHeight) { + clip = new Rect(-overscan, -overscan, stackWidth + overscan, frontHeight); + } + } else { + targetScale = 1f; + targetTy = -(newerHeights + (depth * gap)); + } + + // transform-origin: top center. + card.setPivotX(stackWidth / 2f); + card.setPivotY(0f); + + if (animate || (arrival && card != entranceView)) { + // The clip animates in lockstep with the card (same + // duration and curve), starting clamped to the card's + // body — a visual no-op, but it guarantees the sweeping + // edge stays at or above the front card's bottom for the + // whole flight. + Rect startClip = card.getClipBounds(); + if (startClip == null) { + startClip = new Rect(-overscan, -overscan, stackWidth + overscan, heights[i]); + } else if (startClip.bottom > heights[i]) { + startClip = new Rect(startClip.left, startClip.top, startClip.right, heights[i]); + } + card.setClipBounds(startClip); + ObjectAnimator clipAnimator = ObjectAnimator.ofObject(card, "clipBounds", new RectEvaluator(), startClip, clip); + clipAnimator.setDuration(350); + clipAnimator.setInterpolator(new PathInterpolator(0.4f, 0f, 0.2f, 1f)); + clipAnimator.start(); + + card.animate() + .translationY(targetTy) + .scaleX(targetScale) + .scaleY(targetScale) + .alpha(targetAlpha) + .setDuration(350) + .setInterpolator(new PathInterpolator(0.4f, 0f, 0.2f, 1f)) + .start(); + } else if (arrival) { + // The new front card materializes in its slot — a fade + // with a slight scale-up and NO travel, so it can never + // read as arriving from somewhere else on the screen. + card.animate().cancel(); + card.setClipBounds(clip); + card.setTranslationY(targetTy); + card.setScaleX(0.97f); + card.setScaleY(0.97f); + card.setAlpha(0f); + rampCardElevationWithFade(card); + card.animate() + .translationY(targetTy) + .scaleX(targetScale) + .scaleY(targetScale) + .alpha(1f) + .setDuration(350) + .setInterpolator(new PathInterpolator(0.4f, 0f, 0.2f, 1f)) + .start(); + } else { + card.animate().cancel(); + card.setTranslationY(targetTy); + card.setScaleX(targetScale); + card.setScaleY(targetScale); + card.setAlpha(targetAlpha); + card.setClipBounds(clip); + } + + newerHeights += heights[i]; + } + + // The very first notification has no stack to emerge from — it + // slides up with a fade, matching the web widget's entrance. + if (entranceView != null && !animate && !arrival) { + // The very first notification materializes in place too — + // fade plus a slight scale-up, no travel. + entranceView.animate().cancel(); + entranceView.setAlpha(0f); + entranceView.setScaleX(0.97f); + entranceView.setScaleY(0.97f); + rampCardElevationWithFade(entranceView); + entranceView.animate() + .alpha(1f) + .scaleX(1f) + .scaleY(1f) + .setDuration(350) + .setInterpolator(new PathInterpolator(0.4f, 0f, 0.2f, 1f)) + .start(); + } + + // The close button floats 9dp outside the stack's visual top + // corner and rides along as the stack expands or collapses. + if (closeButtonContainer != null) { + int overhang = convertDpToPixel(9, activity); + float visualTop = collapsed ? frameHeight - (frontHeight + headroom) : frameHeight - expandedHeight; + float closeTy = visualTop - overhang; + boolean isRTL = notificationStackFrame.getLayoutDirection() == View.LAYOUT_DIRECTION_RTL; + closeButtonContainer.setTranslationX(isRTL ? -overhang : overhang); + if (animate || arrival) { + closeButtonContainer.animate() + .translationY(closeTy) + .setDuration(350) + .setInterpolator(new PathInterpolator(0.4f, 0f, 0.2f, 1f)) + .start(); + } else { + closeButtonContainer.animate().cancel(); + closeButtonContainer.setTranslationY(closeTy); + } + } + } catch (Exception exp) { + } + } + public void setMessageCounter(int messageCounter) { this.messageCounter = messageCounter; @@ -766,6 +1096,10 @@ public void run() { feedbackButtonRelativeLayout.setVisibility(View.INVISIBLE); } } + + // The notification container anchors to the button when it + // is visible — follow the state change. + applyNotificationContainerConstraints(ActivityUtil.getCurrentActivity()); } }); } catch (Error | Exception ignore) { diff --git a/gleap/src/main/java/io/gleap/GleapNotificationStyle.java b/gleap/src/main/java/io/gleap/GleapNotificationStyle.java new file mode 100644 index 0000000..3964e32 --- /dev/null +++ b/gleap/src/main/java/io/gleap/GleapNotificationStyle.java @@ -0,0 +1,196 @@ +package io.gleap; + +import static io.gleap.GleapHelper.convertDpToPixel; + +import android.app.Activity; +import android.graphics.Color; +import android.icu.text.RelativeDateTimeFormatter; +import android.icu.util.ULocale; +import android.os.Build; + +import java.text.SimpleDateFormat; +import java.util.Date; +import java.util.Locale; +import java.util.TimeZone; + +/** + * Colors, radii and the relative-time label for the in-app notification cards. + * These are the same values the web widget derives from the flow config in + * injectStyledCSS, so a dark-themed project gets dark cards on every platform, + * independent of the OS appearance. + */ +class GleapNotificationStyle { + + static int backgroundColor() { + try { + return Color.parseColor(GleapConfig.getInstance().getBackgroundColor()); + } catch (Exception exp) { + return Color.WHITE; + } + } + + // YIQ >= 160 reads as a light background — the same threshold the web + // widget's calculateContrast uses. + static boolean isDarkTheme() { + int background = backgroundColor(); + double yiq = ((Color.red(background) * 299d) + (Color.green(background) * 587d) + (Color.blue(background) * 114d)) / 1000d; + return yiq < 160d; + } + + static int contrastColor() { + return isDarkTheme() ? Color.WHITE : Color.BLACK; + } + + // Shifts every channel by `amount`, clamped — mirrors the web widget's + // calculateShadeColor, which derives the muted text color from the + // background. + static int shadeOfColor(int color, int amount) { + int red = Math.max(0, Math.min(255, Color.red(color) + amount)); + int green = Math.max(0, Math.min(255, Color.green(color) + amount)); + int blue = Math.max(0, Math.min(255, Color.blue(color) + amount)); + return Color.rgb(red, green, blue); + } + + static int subTextColor() { + return shadeOfColor(backgroundColor(), isDarkTheme() ? 100 : -120); + } + + // A drop shadow alone cannot separate a dark card from a dark page, so the + // card also carries a hairline in the direction the theme needs. + static int hairlineColor() { + if (isDarkTheme()) { + return Color.argb(26, 255, 255, 255); + } + return Color.argb(10, 0, 0, 0); + } + + // The card corner radius, derived from the project's border radius setting + // exactly like the web widget's containerRadius. + static int containerRadiusPx(Activity activity) { + int containerRadius = Math.round(GleapConfig.getInstance().getBorderRadius() * 0.8f); + return convertDpToPixel(containerRadius, activity); + } + + // The bot's avatar is a rounded rectangle rather than a circle — the same + // shape the dashboard and the messenger give it. Derived from the project's + // radius so a squared-off widget theme keeps squared-off marks; 7dp at the + // default 20 on the 32dp notification avatar. + static int botAvatarRadiusPx(Activity activity, int avatarSizeDp) { + int formItemRadius = Math.round(GleapConfig.getInstance().getBorderRadius() * 0.4f); + int radius = Math.max(2, Math.round((formItemRadius * avatarSizeDp) / 36f)); + return convertDpToPixel(radius, activity); + } + + // The stack (and with it every card) spans the same width on every device: + // 90% of the smaller screen dimension, capped at 320dp — matching the web + // widget and the iOS SDK. + static int stackWidthPx(Activity activity) { + try { + android.util.DisplayMetrics displayMetrics = activity.getResources().getDisplayMetrics(); + int smallerDimension = Math.min(displayMetrics.widthPixels, displayMetrics.heightPixels); + return Math.min((int) (smallerDimension * 0.9f), convertDpToPixel(320, activity)); + } catch (Exception exp) { + return convertDpToPixel(320, activity); + } + } + + // The stack frame's one fixed height: tall enough for any expanded stack + // (4 news-sized cards with gaps fit comfortably), so it never needs to be + // resized when notifications come and go — resizing would re-anchor the + // bottom-pinned cards mid-animation. + static int stackFrameHeightPx(Activity activity) { + try { + android.util.DisplayMetrics displayMetrics = activity.getResources().getDisplayMetrics(); + return Math.max(displayMetrics.heightPixels, convertDpToPixel(1200, activity)); + } catch (Exception exp) { + return convertDpToPixel(1200, activity); + } + } + + private static Date parseIsoDate(String value) { + if (value == null || value.length() == 0) { + return null; + } + + String[] patterns = new String[]{"yyyy-MM-dd'T'HH:mm:ss.SSS'Z'", "yyyy-MM-dd'T'HH:mm:ss'Z'"}; + for (String pattern : patterns) { + try { + SimpleDateFormat format = new SimpleDateFormat(pattern, Locale.US); + format.setTimeZone(TimeZone.getTimeZone("UTC")); + return format.parse(value); + } catch (Exception exp) { + } + } + return null; + } + + /** + * "now" / "5 minutes ago" label for a notification's age, localized through + * the platform's ICU formatter. Returns null whenever a truthful label can't + * be produced (no timestamp, an unparsable one, or an OS without the + * formatter), so callers drop the label instead of printing a placeholder. + * + * The age is taken from sendAt rather than createdAt: a scheduled outbound + * is written to the database long before it is delivered, and its creation + * time would surface as an hours-old message the user just received. + */ + static String relativeTimeLabel(String sendAt, String createdAt) { + try { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.N) { + return null; + } + + Date date = parseIsoDate(sendAt); + if (date == null) { + date = parseIsoDate(createdAt); + } + if (date == null) { + return null; + } + + RelativeDateTimeFormatter formatter; + try { + // The widget's language override, falling back to the device locale. + String language = GleapConfig.getInstance().getLanguage(); + if (language != null && language.length() > 0) { + formatter = RelativeDateTimeFormatter.getInstance(ULocale.forLanguageTag(language.replace("_", "-"))); + } else { + formatter = RelativeDateTimeFormatter.getInstance(); + } + } catch (Exception exp) { + formatter = RelativeDateTimeFormatter.getInstance(); + } + + // Clamped at 0: a notification scheduled a few seconds ahead (or a + // client clock running behind the server's) must never read as + // "in 1 minute". Under a minute collapses to "now" rather than + // ticking "9 seconds ago". + double seconds = Math.min(0d, (date.getTime() - System.currentTimeMillis()) / 1000d); + if (seconds > -60d) { + return formatter.format(RelativeDateTimeFormatter.Direction.PLAIN, RelativeDateTimeFormatter.AbsoluteUnit.NOW); + } + + // Promote the value to the largest unit it still fills, so 90 + // minutes reads as "1 hour", not "90 minutes". + double duration = Math.abs(seconds) / 60d; + double[] amounts = new double[]{60d, 24d, 7d, 4.34524d, 12d, Double.POSITIVE_INFINITY}; + RelativeDateTimeFormatter.RelativeUnit[] units = new RelativeDateTimeFormatter.RelativeUnit[]{ + RelativeDateTimeFormatter.RelativeUnit.MINUTES, + RelativeDateTimeFormatter.RelativeUnit.HOURS, + RelativeDateTimeFormatter.RelativeUnit.DAYS, + RelativeDateTimeFormatter.RelativeUnit.WEEKS, + RelativeDateTimeFormatter.RelativeUnit.MONTHS, + RelativeDateTimeFormatter.RelativeUnit.YEARS + }; + for (int i = 0; i < amounts.length; i++) { + if (duration < amounts[i]) { + return formatter.format(Math.round(duration), RelativeDateTimeFormatter.Direction.LAST, units[i]); + } + duration /= amounts[i]; + } + } catch (Exception exp) { + } + + return null; + } +} diff --git a/gleap/src/main/java/io/gleap/GleapSender.java b/gleap/src/main/java/io/gleap/GleapSender.java index 9544bc6..7fae48d 100644 --- a/gleap/src/main/java/io/gleap/GleapSender.java +++ b/gleap/src/main/java/io/gleap/GleapSender.java @@ -3,10 +3,18 @@ class GleapSender { private String name; private String profileImageUrl; + // Absent on payloads from servers that don't send it yet — those fall + // through to the teammate avatar shape. + private boolean isBot; public GleapSender(String name, String profileImageUrl) { + this(name, profileImageUrl, false); + } + + public GleapSender(String name, String profileImageUrl, boolean isBot) { this.name = name; this.profileImageUrl = profileImageUrl; + this.isBot = isBot; } public String getName() { @@ -17,11 +25,16 @@ public String getProfileImageUrl() { return profileImageUrl; } + public boolean isBot() { + return isBot; + } + @Override public String toString() { return "GleapSender{" + "name='" + name + '\'' + ", profileImageUrl='" + profileImageUrl + '\'' + + ", isBot=" + isBot + '}'; } } diff --git a/gradle.properties b/gradle.properties index daed575..3805c9d 100644 --- a/gradle.properties +++ b/gradle.properties @@ -13,7 +13,7 @@ android.useAndroidX=true # http://www.gradle.org/docs/current/userguide/multi_project_builds.html#sec:decoupled_projects # org.gradle.parallel=true -VERSION_NAME=16.4.2 +VERSION_NAME=16.4.5 VERSION_CODE=160402 GROUP=io.gleap POM_ARTIFACT_ID=gleap-android-sdk