package com.wytehat.btlogger; import android.content.Context; import android.graphics.Canvas; import android.graphics.Color; import android.graphics.Paint; import android.graphics.RadialGradient; import android.graphics.Shader; import android.view.MotionEvent; import android.view.ScaleGestureDetector; import android.view.View; import java.util.ArrayList; import java.util.Collections; import java.util.Comparator; import java.util.HashMap; import java.util.List; public class BluetoothRadarView extends View { public interface Listener { void onTargetSelected(String address); } public static class Target { public String address; public String name; public int rssi; float x, y; public Target(String address, String name, int rssi) { this.address = address; this.name = name; this.rssi = rssi; } } private final Paint paint = new Paint(Paint.ANTI_ALIAS_FLAG); private final ArrayList targets = new ArrayList(); private final HashMap targetAngles = new HashMap(); private final HashMap displayedRadii = new HashMap(); private Listener listener; private boolean scanning; private final ScaleGestureDetector scaleDetector; private float zoom = 1.0f; private boolean suppressTap; private double sweepAngle = -90.0; /* Draw-time scratch, reused every frame -- see onDraw. */ private android.graphics.RadialGradient glow; private float glowCx = -1f, glowCy = -1f, glowR = -1f; private final ArrayList ordered = new ArrayList(); private final Comparator byAngle = new Comparator() { public int compare(Target first, Target second) { float a = angleFor(first.address); float b = angleFor(second.address); return a < b ? -1 : (a > b ? 1 : 0); } }; public BluetoothRadarView(Context context) { super(context); scaleDetector = new ScaleGestureDetector(context, new ScaleGestureDetector.SimpleOnScaleGestureListener() { public boolean onScaleBegin(ScaleGestureDetector detector) { suppressTap = true; return true; } public boolean onScale(ScaleGestureDetector detector) { zoom = clamp(zoom * detector.getScaleFactor(), 1.0f, 3.5f); invalidate(); return true; } }); paint.setTypeface(android.graphics.Typeface.create("sans", android.graphics.Typeface.NORMAL)); setBackgroundColor(Color.rgb(4, 24, 29)); setLayerType(View.LAYER_TYPE_SOFTWARE, null); } public synchronized void setScanning(boolean value) { if (value && !scanning) zoom = 1.0f; scanning = value; invalidate(); } public void setListener(Listener value) { listener = value; } public synchronized void setTargets(List values) { for (int i = 0; i < values.size(); i++) { Target target = values.get(i); if (!targetAngles.containsKey(target.address)) targetAngles.put(target.address, Float.valueOf(findOpenAngle(target.address))); } targets.clear(); targets.addAll(values); // While scanning, onDraw already re-posts itself every 33ms; an // extra invalidate per update just forces redundant frames. if (!scanning) invalidate(); } protected synchronized void onDraw(Canvas canvas) { super.onDraw(canvas); float cx = getWidth() / 2f; float cy = getHeight() / 2f; canvas.save(); canvas.scale(zoom, zoom, cx, cy); float radius = Math.min(cx, cy) - 34; float glowRadius = radius + 34f; float edgeStop = radius / glowRadius; paint.setStyle(Paint.Style.FILL); // Rebuilt only when the geometry moves. This used to allocate a // six-stop gradient and two arrays on every frame of a 30fps sweep. if (glow == null || glowCx != cx || glowCy != cy || glowR != glowRadius) { glow = new android.graphics.RadialGradient(cx, cy, glowRadius, new int[] { Color.TRANSPARENT, Color.argb(5, 35, 180, 110), Color.argb(24, 45, 225, 140), Color.argb(48, 55, 235, 150), Color.argb(18, 35, 190, 120), Color.TRANSPARENT }, new float[] { 0f, 0.55f, edgeStop - 0.05f, edgeStop, edgeStop + 0.10f, 1f }, android.graphics.Shader.TileMode.CLAMP); glowCx = cx; glowCy = cy; glowR = glowRadius; } paint.setShader(glow); canvas.drawCircle(cx, cy, glowRadius, paint); paint.setShader(null); paint.setStyle(Paint.Style.STROKE); paint.setStrokeWidth(2.4f); paint.setColor(Color.argb(95, 55, 220, 145)); paint.setShadowLayer(12f, 0f, 0f, Color.argb(65, 45, 225, 145)); canvas.drawCircle(cx, cy, radius, paint); paint.clearShadowLayer(); paint.setColor(Color.argb(22, 65, 225, 155)); paint.setStrokeWidth(6f); for (int i = 1; i <= 4; i++) canvas.drawCircle(cx, cy, radius * i / 4f, paint); paint.setColor(Color.rgb(38, 145, 110)); paint.setStrokeWidth(2.8f); for (int i = 1; i <= 4; i++) canvas.drawCircle(cx, cy, radius * i / 4f, paint); paint.setColor(Color.argb(28, 55, 205, 145)); paint.setStrokeWidth(3.5f); canvas.drawLine(cx - radius, cy, cx + radius, cy, paint); canvas.drawLine(cx, cy - radius, cx, cy + radius, paint); paint.setColor(Color.rgb(35, 125, 100)); paint.setStrokeWidth(1.8f); canvas.drawLine(cx - radius, cy, cx + radius, cy, paint); canvas.drawLine(cx, cy - radius, cx, cy + radius, paint); if (scanning) sweepAngle = (System.currentTimeMillis() % 6000L) * 360.0 / 6000.0 - 90.0; double angle = sweepAngle + 90.0; double radians = Math.toRadians(angle - 90); float sweepX = cx + (float)Math.cos(radians) * radius; float sweepY = cy + (float)Math.sin(radians) * radius; paint.setColor(Color.argb(35, 70, 255, 170)); paint.setStrokeWidth(16); canvas.drawLine(cx, cy, sweepX, sweepY, paint); paint.setColor(Color.argb(85, 70, 255, 170)); paint.setStrokeWidth(8); canvas.drawLine(cx, cy, sweepX, sweepY, paint); paint.setColor(Color.rgb(65, 255, 170)); paint.setStrokeWidth(2.5f); canvas.drawLine(cx, cy, sweepX, sweepY, paint); paint.setStyle(Paint.Style.FILL); paint.setColor(Color.WHITE); canvas.drawCircle(cx, cy, 7, paint); paint.setTextAlign(Paint.Align.CENTER); paint.setTextSize(12 * getResources().getDisplayMetrics().scaledDensity); canvas.drawText("YOU", cx, cy + 24, paint); // Reused across frames; a fresh list and comparator per frame is // pure garbage at 30fps. ordered.clear(); ordered.addAll(targets); Collections.sort(ordered, byAngle); float density = getResources().getDisplayMetrics().scaledDensity; for (int i = 0; i < ordered.size(); i++) { Target target = ordered.get(i); float degrees = angleFor(target.address); double targetAngle = Math.toRadians(degrees); float desiredRadius = radius * radialPosition(target.rssi); Float previous = displayedRadii.get(target.address); float shownRadius = previous == null ? desiredRadius : previous.floatValue() + (desiredRadius - previous.floatValue()) * 0.055f; displayedRadii.put(target.address, Float.valueOf(shownRadius)); float cos = (float)Math.cos(targetAngle); float sin = (float)Math.sin(targetAngle); target.x = cx + cos * shownRadius; target.y = cy + sin * shownRadius; paint.setStyle(Paint.Style.FILL); paint.setColor(target.rssi >= -60 ? Color.rgb(255, 70, 70) : target.rssi >= -80 ? Color.rgb(255, 180, 40) : Color.rgb(40, 150, 255)); canvas.drawCircle(target.x, target.y, 9, paint); paint.setTextSize(9.5f * density); paint.setTextAlign(Paint.Align.CENTER); String label = target.name == null ? "Unknown" : target.name; if (label.length() > 14) label = label.substring(0, 14); String signal = target.rssi + " dBm"; float textWidth = Math.max(paint.measureText(label), paint.measureText(signal)); int lane = target.address == null ? 0 : Math.abs(target.address.hashCode() / 360) % 5 - 2; float shift = lane * 10f * density; float labelX = target.x + cos * (15f + textWidth / 2f) - sin * shift; float labelY = target.y + sin * (17f + 12f * density) + cos * shift; labelX = clamp(labelX, textWidth / 2f + 3f, getWidth() - textWidth / 2f - 3f); labelY = clamp(labelY, 12f * density, getHeight() - 12f * density); paint.setColor(Color.WHITE); paint.setShadowLayer(3f, 1f, 1f, Color.BLACK); canvas.drawText(label, labelX, labelY - 2f, paint); paint.setTextSize(8.5f * density); canvas.drawText(signal, labelX, labelY + 10f * density, paint); paint.clearShadowLayer(); } canvas.restore(); if (scanning) postInvalidateDelayed(33); } public synchronized boolean onTouchEvent(MotionEvent event) { if (event.getActionMasked() == MotionEvent.ACTION_DOWN && getParent() != null) getParent().requestDisallowInterceptTouchEvent(true); scaleDetector.onTouchEvent(event); if (event.getPointerCount() > 1) { suppressTap = true; return true; } if (event.getAction() != MotionEvent.ACTION_UP) return true; if (suppressTap) { suppressTap = false; return true; } float cx = getWidth() / 2f; float cy = getHeight() / 2f; float touchX = cx + (event.getX() - cx) / zoom; float touchY = cy + (event.getY() - cy) / zoom; Target closest = null; float best = Float.MAX_VALUE; for (int i = 0; i < targets.size(); i++) { Target target = targets.get(i); float dx = touchX - target.x; float dy = touchY - target.y; float distance = dx * dx + dy * dy; if (distance < best) { best = distance; closest = target; } } float limit = 48 * getResources().getDisplayMetrics().density / zoom; if (closest != null && best <= limit * limit && listener != null) listener.onTargetSelected(closest.address); return true; } private float angleFor(String address) { Float value = targetAngles.get(address); if (value != null) return value.floatValue(); float created = findOpenAngle(address); targetAngles.put(address, Float.valueOf(created)); return created; } private float findOpenAngle(String address) { if (targetAngles.isEmpty()) return address == null ? -90f : (Math.abs(address.hashCode()) % 360) - 90f; float bestAngle = 0f; float bestGap = -1f; for (int candidate = 0; candidate < 360; candidate += 5) { float nearest = 360f; for (Float used : targetAngles.values()) nearest = Math.min(nearest, circularDistance(candidate, used.floatValue())); if (nearest > bestGap) { bestGap = nearest; bestAngle = candidate; } } return bestAngle; } private float circularDistance(float first, float second) { float difference = Math.abs(first - second) % 360f; return Math.min(difference, 360f - difference); } private float radialPosition(int rssi) { return clamp((-rssi - 45f) / 60f, 0.12f, 0.94f); } private float clamp(float value, float min, float max) { return Math.max(min, Math.min(max, value)); } }