/* Swisscom Safe Connect Copyright (C) 2014 Swisscom This program is free software: you can redistribute it and/or modify it under the terms of the GNU General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version. This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details. You should have received a copy of the GNU General Public License along with this program. If not, see <http://www.gnu.org/licenses/>. */ package com.swisscom.safeconnect.view; import android.content.Context; import android.content.res.Resources; import android.content.res.TypedArray; import android.graphics.Canvas; import android.graphics.DashPathEffect; import android.graphics.Matrix; import android.graphics.Paint; import android.graphics.Path; import android.graphics.PointF; import android.graphics.Rect; import android.graphics.RectF; import android.graphics.drawable.Drawable; import android.os.AsyncTask; import android.os.Parcel; import android.os.Parcelable; import android.text.TextPaint; import android.util.AttributeSet; import android.view.GestureDetector; import android.view.MotionEvent; import android.view.ScaleGestureDetector; import android.view.View; import android.widget.ImageView; import com.svg.svgandroid.SVG; import com.svg.svgandroid.SVGBuilder; import com.swisscom.safeconnect.R; import com.swisscom.safeconnect.model.PlumberLastConnectionLogResponse; import com.swisscom.safeconnect.model.PlumberLastConnectionLogResponseList; import com.swisscom.safeconnect.utils.Fonts; import java.io.Serializable; import java.util.ArrayList; import java.util.Arrays; import java.util.Collections; import java.util.List; /** * Created by vadim on 22.09.14. */ public class WorldMapView extends ImageView { // used to calibrate the map private static final int offsetX = 0; private static final int offsetY = 0; private static final double radian = 0.017453293; private static final int legendX = 5; private static final int legendY = 5; // Robinson's tables private static final double[] AA = {0.8487,0.84751182,0.84479598,0.840213,0.83359314,0.8257851, 0.814752,0.80006949,0.78216192,0.76060494,0.73658673,0.7086645,0.67777182,0.64475739, 0.60987582,0.57134484,0.52729731,0.48562614,0.45167814}; private static final double[] BB = {0,0.0838426,0.1676852,0.2515278,0.3353704,0.419213, 0.5030556,0.5868982,0.67182264,0.75336633,0.83518048,0.91537187,0.99339958, 1.06872269,1.14066505,1.20841528,1.27035062,1.31998003,1.3523}; private int imgW, imgH; private int viewW, viewH; private double earthRadius; // coordinates of the points on the map //private Point vpnServerPos = new Point(46.946702, 7.444652); private Point vpnServerPos = new Point(46.948049, 7.449630); private Point userPos = vpnServerPos; private PlumberLastConnectionLogResponse[] connPoints = new PlumberLastConnectionLogResponse[0]; private Path userPosShape; private float shapeSize, collisionArea, textYShift; private float legendPadding, legendPadding2, zoomPadding, httpConnSize; // projected pos points ready to be drawn on the map private Point projVpnPos; private PointF drawVpnPos; private Point projUserPos; private PointF drawUserPos; private Point[] projConnPoints; private PointF[] drawConnPoints; private RectF bounds; private float scaleFactor, scaledPadding; private float transX, transY; private Matrix matrix = new Matrix(); private Paint userPaintIn, userPaintOut, discUserPaint, discUserPaintFilled; private Paint vpnPaintIn, vpnPaintOut, vpnPaintNoNetwork; private Paint blackPaint, whitePaint; private Paint connPaint, userVpnPaint; private Paint cityTextPaint; private boolean disconnected = true; private boolean noNetwork = false; private boolean loaded = false; private boolean tabletLayout = false; private boolean doNotRecalcBounds = false; private GestureDetector gestureDetector; private ScaleGestureDetector scaleDetector; private OnClickListener userInteractionListener; private static class Point implements Serializable { private static final long serialVersionUID = 3941286050907028864L; public double x, y; public Point(double x, double y) { this.x = x; this.y = y; } } private static class SvgHolder { static Drawable map; public static void loadGraphics(Resources res) { if (map == null) { SVG worldmap = new SVGBuilder() .readFromResource(res, R.raw.worldmap_robinson_map) .build(); map = worldmap.getDrawable(); } } } private class SvgLoader extends AsyncTask<Void, Void, Void> { @Override protected Void doInBackground(Void... params) { SvgHolder.loadGraphics(getResources()); return null; } @Override protected void onPostExecute(Void aVoid) { super.onPostExecute(aVoid); loaded = true; setImageDrawable(SvgHolder.map); reproject(); calcBounds(); invalidate(); } } public WorldMapView(Context context, AttributeSet attrs) { super(context, attrs); TypedArray a = context.obtainStyledAttributes(attrs, R.styleable.WorldMapView); tabletLayout = a.getBoolean(R.styleable.WorldMapView_tabletLayout, false); a.recycle(); shapeSize = 6 * (getContext().getResources().getDisplayMetrics().density + 0.5f); httpConnSize = 4 * (getContext().getResources().getDisplayMetrics().density + 0.5f); collisionArea = 13 * (getContext().getResources().getDisplayMetrics().density + 0.5f); textYShift = 7 * (getContext().getResources().getDisplayMetrics().density + 0.5f); legendPadding = 3 * (getContext().getResources().getDisplayMetrics().density + 0.5f); legendPadding2 = 4 * (getContext().getResources().getDisplayMetrics().density + 0.5f); zoomPadding = 25 * (getContext().getResources().getDisplayMetrics().density + 0.5f); setLayerType(View.LAYER_TYPE_SOFTWARE, null); setScaleType(ScaleType.MATRIX); SvgLoader loader = new SvgLoader(); loader.execute(); gestureDetector = new GestureDetector(getContext(), new GestureListener()); scaleDetector = new ScaleGestureDetector(getContext(), new ScaleListener()); init(); } static class SavedState extends BaseSavedState { boolean disconnected; boolean noNetwork; Point vpnServerPos, userPos; PlumberLastConnectionLogResponse[] connPoints; SavedState(Parcelable superState) { super(superState); } public SavedState(Parcel source) { super(source); disconnected = (Boolean) source.readValue(null); noNetwork = (Boolean) source.readValue(null); vpnServerPos = (Point) source.readValue(Point.class.getClassLoader()); userPos = (Point) source.readValue(Point.class.getClassLoader()); Object[] cp = (Object [])source.readValue(PlumberLastConnectionLogResponse.class.getClassLoader()); connPoints = Arrays.copyOf(cp, cp.length, PlumberLastConnectionLogResponse[].class); } @Override public void writeToParcel(Parcel dest, int flags) { super.writeToParcel(dest, flags); dest.writeValue(disconnected); dest.writeValue(noNetwork); dest.writeValue(vpnServerPos); dest.writeValue(userPos); dest.writeValue(connPoints); } public static final Parcelable.Creator<SavedState> CREATOR = new Parcelable.Creator<SavedState>() { public SavedState createFromParcel(Parcel in) { return new SavedState(in); } public SavedState[] newArray(int size) { return new SavedState[size]; } }; } @Override protected Parcelable onSaveInstanceState() { Parcelable superState = super.onSaveInstanceState(); SavedState ss = new SavedState(superState); ss.disconnected = disconnected; ss.noNetwork = noNetwork; ss.connPoints = connPoints; ss.userPos = userPos; ss.vpnServerPos = vpnServerPos; return ss; } @Override protected void onRestoreInstanceState(Parcelable state) { if(!(state instanceof SavedState)) { super.onRestoreInstanceState(state); return; } SavedState ss = (SavedState)state; super.onRestoreInstanceState(ss.getSuperState()); disconnected = ss.disconnected; noNetwork = ss.noNetwork; connPoints = ss.connPoints; vpnServerPos = ss.vpnServerPos; userPos = ss.userPos; } /** * prepares drawing objects */ private void init() { vpnPaintIn = new Paint(Paint.ANTI_ALIAS_FLAG); vpnPaintIn.setColor(getResources().getColor(R.color.light_blue)); vpnPaintNoNetwork = new Paint(Paint.ANTI_ALIAS_FLAG); vpnPaintNoNetwork.setColor(getResources().getColor(R.color.grey)); vpnPaintOut = new Paint(Paint.ANTI_ALIAS_FLAG); vpnPaintOut.setColor(getResources().getColor(R.color.lighter_blue)); vpnPaintOut.setAlpha(150); userPaintIn = new Paint(Paint.ANTI_ALIAS_FLAG); userPaintIn.setColor(getResources().getColor(R.color.green)); discUserPaint = new Paint(Paint.ANTI_ALIAS_FLAG); discUserPaint.setColor(getResources().getColor(R.color.red)); discUserPaint.setStyle(Paint.Style.FILL_AND_STROKE); discUserPaint.setPathEffect(new DashPathEffect(new float[] {10, 5}, 0)); discUserPaint.setStrokeWidth(3); discUserPaintFilled = new Paint(Paint.ANTI_ALIAS_FLAG); discUserPaintFilled.setColor(getResources().getColor(R.color.red)); userPaintOut = new Paint(Paint.ANTI_ALIAS_FLAG); userPaintOut.setColor(getResources().getColor(R.color.lighter_green)); userPaintOut.setAlpha(150); blackPaint = new Paint(Paint.ANTI_ALIAS_FLAG); blackPaint.setColor(getResources().getColor(android.R.color.black)); whitePaint = new Paint(Paint.ANTI_ALIAS_FLAG); whitePaint.setColor(getResources().getColor(R.color.white)); connPaint = new Paint(Paint.ANTI_ALIAS_FLAG); connPaint.setColor(getResources().getColor(android.R.color.black)); connPaint.setPathEffect(new DashPathEffect(new float[] {10, 5}, 0)); connPaint.setStyle(Paint.Style.STROKE); userVpnPaint = new Paint(Paint.ANTI_ALIAS_FLAG); userVpnPaint.setColor(getResources().getColor(R.color.green)); userVpnPaint.setStyle(Paint.Style.STROKE); userVpnPaint.setStrokeWidth(3); cityTextPaint = new TextPaint(Paint.ANTI_ALIAS_FLAG); cityTextPaint.setTypeface(Fonts.FONT_NORMAL); cityTextPaint.setColor(getResources().getColor(android.R.color.black)); cityTextPaint.setTextSize(12 * (getContext().getResources().getDisplayMetrics().density + 0.5f)); } @Override protected void onSizeChanged(int w, int h, int oldw, int oldh) { super.onSizeChanged(w, h, oldw, oldh); viewW = w; viewH = h; if (loaded) { reproject(); } } private void reproject() { imgW = getDrawable().getIntrinsicWidth(); imgH = getDrawable().getIntrinsicHeight(); earthRadius = (imgW / 2.666269758) / 2; // reproject all points projVpnPos = projectToScreen(vpnServerPos.x, vpnServerPos.y); projUserPos = projectToScreen(userPos.x, userPos.y); projectConnPoints(); calcBounds(); } @Override protected void onDraw(Canvas canvas) { super.onDraw(canvas); if (!loaded) return; if (!noNetwork && isUserPosKnown()) { canvas.drawLine(drawUserPos.x, drawUserPos.y, drawVpnPos.x, drawVpnPos.y, disconnected ? discUserPaint : userVpnPaint); } // drawing connections Path connPath; PlumberLastConnectionLogResponse conn; List<PointF> drawnConn = new ArrayList<PointF>(5); for (int i = 0; i < projConnPoints.length; i++) { conn = connPoints[i]; connPath = new Path(); connPath.moveTo(drawVpnPos.x, drawVpnPos.y); connPath.lineTo(drawConnPoints[i].x, drawConnPoints[i].y); // using path, because dashed line does not work! canvas.drawPath(connPath, connPaint); canvas.drawCircle(drawConnPoints[i].x, drawConnPoints[i].y, httpConnSize, blackPaint); // check already drawn points and texts so we don't draw the text that could // cause overlapping with some other text boolean drawingAllowed = true; if (drawConnPoints[i].x < 0 || drawConnPoints[i].y < 0 || drawConnPoints[i].x > viewW || drawConnPoints[i].y > viewH) { drawingAllowed = false; } else { for (PointF dp : drawnConn) { if (Math.abs(drawConnPoints[i].x - dp.x) < collisionArea && Math.abs(drawConnPoints[i].y - dp.y) < collisionArea) { drawingAllowed = false; break; } } } if (!drawingAllowed) continue; // ok to draw StringBuilder sb = new StringBuilder(conn.getCountryCode()); if (conn.getCity() != null && !conn.getCity().isEmpty()) { sb.append(", "); sb.append(conn.getCity()); } String city = sb.toString(); float textWidth = cityTextPaint.measureText(city); float textPosX = drawConnPoints[i].x - textWidth / 2; if (textPosX < collisionArea) { textPosX = collisionArea; } else if (textPosX+textWidth > viewW-collisionArea) { textPosX = viewW-collisionArea-textWidth; } canvas.drawText(city, textPosX, drawConnPoints[i].y - textYShift, cityTextPaint); drawnConn.add(drawConnPoints[i]); } canvas.drawRect(drawVpnPos.x - shapeSize, drawVpnPos.y - shapeSize, drawVpnPos.x + shapeSize, drawVpnPos.y + shapeSize, noNetwork? vpnPaintNoNetwork : vpnPaintIn); // not drawing own position when no network if (!noNetwork && isUserPosKnown()) { canvas.drawPath(userPosShape, disconnected ? discUserPaintFilled : userPaintIn); } drawLegend(canvas); } private Path getUserPosLegendShape(float startX, float startY) { Path userPosLegendShape = new Path(); userPosLegendShape.moveTo(startX, startY+shapeSize*2); userPosLegendShape.lineTo(startX+shapeSize*2, startY+shapeSize*2); userPosLegendShape.lineTo(startX+shapeSize, startY); userPosLegendShape.lineTo(startX, startY+shapeSize*2); return userPosLegendShape; } private boolean isUserPosKnown() { return userPos != vpnServerPos && !(userPos.x == 0 && userPos.y == 0); } private void drawLegend(Canvas canvas) { Rect rect = new Rect(); float textX = legendX + shapeSize*2 + legendPadding; String legendUser = getContext().getString(R.string.legend_user); String legendVpn = getContext().getString(R.string.legend_vpn); String legendConn = getContext().getString(R.string.legend_conn); cityTextPaint.getTextBounds(legendUser, 0, legendUser.length()-1, rect); float textY = legendY + (shapeSize*2-rect.height())/2 + rect.height(); canvas.drawRect(legendX, legendY, legendX + shapeSize*2, legendX + shapeSize*2, noNetwork? vpnPaintNoNetwork : vpnPaintIn); float textWidth = cityTextPaint.measureText(legendVpn); canvas.drawText(legendVpn, textX, textY, cityTextPaint); if (noNetwork) return; if (tabletLayout) { if (isUserPosKnown()) { canvas.drawPath(getUserPosLegendShape((int) (textX + textWidth + legendPadding2), legendY), disconnected ? discUserPaintFilled : userPaintIn); textX += textWidth + textYShift + shapeSize * 2; canvas.drawText(legendUser, textX, textY, cityTextPaint); textWidth = cityTextPaint.measureText(legendUser); } else { textWidth = 0; } if (connPoints.length > 0) { canvas.drawCircle(textX + textWidth + legendX + legendPadding2*2, legendY + shapeSize, httpConnSize, blackPaint); textX += textWidth + textYShift + shapeSize * 2; canvas.drawText(legendConn, textX, textY, cityTextPaint); } } else { float startY = legendY; if (isUserPosKnown()) { startY = shapeSize * 2 + legendPadding; canvas.drawPath(getUserPosLegendShape(legendX, startY), disconnected ? discUserPaintFilled : userPaintIn); canvas.drawText(legendUser, textX, rect.height() + startY, cityTextPaint); } if (connPoints.length > 0) { startY += shapeSize * 2 + legendPadding; canvas.drawCircle(legendX + shapeSize, startY + httpConnSize/2, httpConnSize, blackPaint); canvas.drawText(legendConn, textX, startY + rect.height()-httpConnSize/2, cityTextPaint); } } } private double roundToNearest(double roundTo, double value) { return Math.floor(value/roundTo)*roundTo; } private Point project(double lat, double lon) { double lonSign = Math.signum(lon); double latSign = Math.signum(lat); // all calculations are positive lat = Math.abs(lat); lon = Math.abs(lon); double low = roundToNearest(5, lat-0.0000000001); low = (lat == 0)? 0: low; double high = low+5; // interpolation indices int lowIndex = (int) low/5; int highIndex = (int) high/5; double ratio = (lat - low) / 5; double adjAA = ((this.AA[highIndex]-this.AA[lowIndex])*ratio)+this.AA[lowIndex]; double adjBB = ((this.BB[highIndex]-this.BB[lowIndex])*ratio)+this.BB[lowIndex]; return new Point(adjAA * lon * radian * lonSign * earthRadius + offsetX, adjBB * latSign * earthRadius + offsetY); } private Point projectToScreen(double lat, double lon) { Point p = project(lat, lon); p.x = (p.x + imgW /2); p.y = imgH /2 - p.y; return p; } private void calcBounds() { if (!loaded) return; if (doNotRecalcBounds) { scaleAndTranslate(scaleFactor, transX, transY); return; } ArrayList<Float> allX = new ArrayList<Float>(connPoints.length + 2); ArrayList<Float> allY = new ArrayList<Float>(connPoints.length + 2); allX.add((float) projVpnPos.x); allX.add((float) projUserPos.x); allY.add((float) projVpnPos.y); allY.add((float) projUserPos.y); for (Point p : projConnPoints) { allX.add((float) p.x); allY.add((float) p.y); } bounds = new RectF(Collections.min(allX) - zoomPadding, Collections.min(allY) - zoomPadding, Collections.max(allX) + zoomPadding, Collections.max(allY) + zoomPadding); float scaleFactorX = 1; if (bounds.right - bounds.left > 0.0001f) { scaleFactorX = viewW / (bounds.right - bounds.left); } float scaleFactorY = 1; if (bounds.bottom - bounds.top > 0.0001f) { scaleFactorY = viewH / (bounds.bottom - bounds.top); } scaleFactor = Math.min(scaleFactorX, scaleFactorY); // limit the scale factor scaleFactor = Math.min(scaleFactor, 5); float centerX = (viewW - (bounds.right - bounds.left) * scaleFactor) / 2; float centerY = (viewH - (bounds.bottom - bounds.top) * scaleFactor) / 2; scaleAndTranslate(scaleFactor, bounds.left - centerX / scaleFactor, bounds.top - centerY / scaleFactor); } private void scaleAndTranslate(float scale, float moveX, float moveY) { transX = moveX; transY = moveY; scaleFactor = scale; matrix.reset(); matrix.preScale(scale, scale); matrix.preTranslate(-transX, -transY); setImageMatrix(matrix); // calc screen positions after the transformations drawVpnPos = new PointF((float)(projVpnPos.x-transX)*scaleFactor, (float)(projVpnPos.y-transY)*scaleFactor); drawUserPos = new PointF((float)(projUserPos.x-transX)*scaleFactor, (float)(projUserPos.y-transY)*scaleFactor); drawConnPoints = new PointF[projConnPoints.length]; for (int i = 0; i < projConnPoints.length; i++) { Point p = projConnPoints[i]; drawConnPoints[i] = new PointF((float)(p.x-transX)*scaleFactor, (float)(p.y-transY)*scaleFactor); } scaledPadding = zoomPadding*scaleFactor; userPosShape = new Path(); userPosShape.moveTo(drawUserPos.x-shapeSize, drawUserPos.y+shapeSize); userPosShape.lineTo(drawUserPos.x+shapeSize, drawUserPos.y+shapeSize); userPosShape.lineTo(drawUserPos.x, drawUserPos.y-shapeSize); userPosShape.lineTo(drawUserPos.x-shapeSize, drawUserPos.y+shapeSize); } /** * sets the coordinates of the VPN server and redraws the map * @param lat * @param lon */ public void setVpnServerPos(double lat, double lon) { vpnServerPos = new Point(lat, lon); projVpnPos = projectToScreen(vpnServerPos.x, vpnServerPos.y); calcBounds(); invalidate(); } /** * sets connection positions and draws them on the map * @param pointsList list from Plumber */ public void setConnPoints(PlumberLastConnectionLogResponseList pointsList) { List<PlumberLastConnectionLogResponse> tmp = new ArrayList<PlumberLastConnectionLogResponse>(5); // make sure that there is no connection to (0, 0) for (PlumberLastConnectionLogResponse r: pointsList.getConnectionLogs()) { if (!(r.getLatitude() == 0 && r.getLongitude() == 0)) { tmp.add(r); } } connPoints = new PlumberLastConnectionLogResponse[tmp.size()]; connPoints = tmp.toArray(connPoints); disconnected = false; noNetwork = false; projectConnPoints(); setUserPos(pointsList.getLatitude(), pointsList.getLongitude()); calcBounds(); invalidate(); } private void projectConnPoints() { projConnPoints = new Point[connPoints.length]; for (int i = 0; i < projConnPoints.length; i++) { projConnPoints[i] = projectToScreen(connPoints[i].getLatitude(), connPoints[i].getLongitude()); } } private void setUserPos(double lat, double lon) { userPos = new Point(lat, lon); projUserPos = projectToScreen(userPos.x, userPos.y); } public void showDisconnected() { projConnPoints = new Point[0]; connPoints = new PlumberLastConnectionLogResponse[0]; disconnected = true; noNetwork = false; calcBounds(); invalidate(); } public void showNoNetwork() { projConnPoints = new Point[0]; connPoints = new PlumberLastConnectionLogResponse[0]; noNetwork = true; disconnected = true; calcBounds(); invalidate(); } @Override public boolean onTouchEvent(MotionEvent event) { scaleDetector.onTouchEvent(event); gestureDetector.onTouchEvent(event); return true; } private class GestureListener implements GestureDetector.OnGestureListener { @Override public boolean onDown(MotionEvent e) { if (userInteractionListener != null) userInteractionListener.onClick(WorldMapView.this); return false; } @Override public void onShowPress(MotionEvent e) { } @Override public boolean onSingleTapUp(MotionEvent e) { return false; } @Override public boolean onScroll(MotionEvent e1, MotionEvent e2, float distanceX, float distanceY) { doNotRecalcBounds = true; float x = transX + distanceX / scaleFactor; float y = transY + distanceY / scaleFactor; if (x < -4800 && distanceX < 0 || x > 4800 && distanceX > 0 || y < -2700 && distanceY < 0 || y > 2700 && distanceY > 0) return true; scaleAndTranslate(scaleFactor, x, y); return true; } @Override public void onLongPress(MotionEvent e) { } @Override public boolean onFling(MotionEvent e1, MotionEvent e2, float velocityX, float velocityY) { return false; } } private class ScaleListener implements ScaleGestureDetector.OnScaleGestureListener { private float fx, fy, tx, ty, s; @Override public boolean onScale(ScaleGestureDetector detector) { float s = scaleFactor * detector.getScaleFactor(); if (s < 0.25 && s < scaleFactor || s > 35) return true; scaleFactor = s; scaleAndTranslate(scaleFactor, tx + fx - detector.getFocusX() / scaleFactor, ty + fy - detector.getFocusY() / scaleFactor); return true; } @Override public boolean onScaleBegin(ScaleGestureDetector detector) { tx = transX; ty = transY; fx = detector.getFocusX()/scaleFactor; fy = detector.getFocusY()/scaleFactor; s = scaleFactor; doNotRecalcBounds = true; if (userInteractionListener != null) userInteractionListener.onClick(WorldMapView.this); return true; } @Override public void onScaleEnd(ScaleGestureDetector detector) { } } public void setAllowRecalcBounds() { doNotRecalcBounds = false; calcBounds(); invalidate(); } public void setOnUserMapInteractionListener(OnClickListener listener) { userInteractionListener = listener; } }