안녕하세요. 오랜만의 포스팅입니다.

오늘은 Quick Circle에서 다양한 시계 화면을 사용하기 위해, 이미 시중(?)에 나와 있는 앱들을 Quick Circle용 시계 화면(Clock face)으로 손쉽게 바꾸는 법을 공유해볼까 해요.

 

Quick Circle은 G4에 탑재된 2015년 버전부터 써드 파티가 만든 시계 화면을 쓸 수 있습니다. 하지만 홍보 부족(^^;)인지 아직은 clock face가 많지 않아요.

Quick Circle clock face를 확보하는 데는 두 가지 + 1 방법이 있습니다.

  • 첫째, 기존의 시계 위젯을 clock face로 포팅하는 것
  • 둘째, 안드로이드 웨어의 watch face를 Quick Circle clock face로 포팅하는 것
  • 셋째인듯 셋째 아닌 셋째, Xposed 용 Quick Circle clock face를 Quick Circle 오리지널로 포팅하는 것

 

Quick Circle clock face는 안드로이드 앱 위젯(이하 그냥 위젯)이기 때문에, 시계 위젯을 clock face로 포팅하는 것은 무척 쉽습니다. 간단하게 app widget metadata XML 파일에서 android:widgetCategory를 0x200으로 바꿔주면 되거든요. (홈 위젯과 clock face로 동시에 쓰고자 할 때는 0x201)

 

안드로이드 웨어의 watch face는 위젯과는 개발 방식이 달라요. Watch 에 올리는 것이다보니 와치 용 서비스로 구현하고, 서비스 엔진에서 시계 화면을 그려주는 방식이죠. 안드로이드 위젯은 사용할 수 있는 기능이 제한적인데 비해, 서비스를 사용하기 때문에 제약이 없는 편이예요. 그래서 보다 다양한 기능을 추가할 수 있죠. 하지만 이런 기능들 중에는 안드로이드 위젯에서는 불가능한 기능도 있기 때문에 안드로이드 웨어의 watch face를 Quick Circle clock face (즉, 위젯)로 "자동 컨버팅" 하는 것은 불가능에 가깝습니다.

하지만 화면을 드로잉 하는 방식은 안드로이드 위젯에서도 사용할 수 있기 때문에, 복잡한 애니메이션 등 위젯에서 구현이 어려운 기능이 없는 watch face라면, Quick Circle clock face로 포팅(!) 할 수 있어요.

 

그리고 XPosed를 이용한 Quick Circle themer 가 있는데요. clock face들은 많이 있지만, 루팅을 하지 않은 일반 사용자는 사용할 수 없어요.

이 XPosed 용 Quick Circle Themer는, 시계 출력 자체는 XPosed(? 혹은 그 모듈?)가 제공하고, 시계 테마로는 이미지와 설정 정보를 담은 XML파일만 제공합니다. 즉 시계 화면 개발자는 이미지 디자인 정도만 하면 충분히 새로운 시계를 만들 수 있다는 거죠.

우리도 XPosed가 하듯이 기본 시계 출력을 만들어 주고, 시계 테마 이미지와 XML 파일을 읽어 Quick Circle clock face로 만들 수 있습니다.

 

자, 지금부터 이 세 종류의 앱을 Quick Circle 용 clock face로 변환하는 방법을 정리해보겠습니다!

Quick Circle clock face를 만들기 위한 시계 배경 (dial) 이미지는 최소 1046x1046 크기를 권장합니다.

 

시계 위젯을 Quick Circle clock face로 

시계 위젯 프로젝트를 열어보면, 다음과 같이 res/xml에 widget metadata 파일이 있습니다. 

파일을 열어보면 <appwidget-provider> 태그 속성 중에, android:widgetCategory라는 속성이 있습니다.

보통 "homescreen"이나 "homescreen | keyguard"라고 되어 있을 거예요. 이 항목을 "0x200"이나 "0x201" (홈 위젯으로도 사용하려면)으로 수정합니다. 만약 android:widgetCateory라는 속성이 없으면 새로 추가하면 됩니다. 

프로젝트를 빌드하여 폰에 설치하면, Quick Circle clock face 목록에 새로 추가한 시계 위젯이 나타날 겁니다. 그 시계를 선택하면 끝!

 

Watch face를 Quick Circle clock face로 

이건 조금 복잡합니다.

안드로이드 웨어 Watch face를 구현할 때는 주로 CanvasWatchfaceService를 사용합니다. 여기서 제공하는 Canvas를 통해 다이얼과 시계 바늘 이미지를 얹어 사용하기도 하고, 직접 그리기도 합니다.

이미지를 사용할 경우, 다음과 같이 포팅할 수 있습니다.

 

이미지를 사용한 Watch face 포팅하기

안드로이드는 AnalogClock 이라는 아날로그 시계 뷰를 기본 제공합니다. 레이아웃 XML 파일에 적절한 이미지를 지정하면, 아무런 코딩없이 바로 아날로그 clock face를 만들 수 있는 좋은 뷰죠.

단점은 초 바늘을 표시할 수 없다는 것.

반드시 초 바늘까지 나타낼 필요가 없다면, 단순히 watch face의 이미지를 가져와서 AnalogClock 에 입히면 됩니다.

 

그럼 AnalogClock을 이용해 초 바늘 없는 clock widget를 만들어 볼까요. 물론 watch face에서는 이미지만 가져오는 걸로~

  1. AppWidget 프로젝트를 하나 만듭니다.
  2. Watch face 에서 사용한 다이얼(시계 배경), 시 표시 용 시계 바늘, 분 표시용 시계 바늘, 프리뷰 등의 이미지를 res/drawable에 복사합니다.
  3. res/layout/{시계 레이아웃 파일}.xml 을 열어, 루트 레이아웃 밑에 다음처럼 입력합니다.

    <AnalogClock
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:dial="@drawable/{다이얼 이미지}"
        android:hand_hour="@drawable/{시 표시 용 시계 바늘 이미지}"
        android:hand_minute="@drawable/{분 표시 용 시계 바늘 이미지}"
        />
  4. res/xml/{App widget metadata 파일}.xml 을 열어 아래처럼 프리뷰 이미지를 설정합니다.

    <appwidget-provider xmlns:android="http://schemas.android.com/apk/res/android"
        ...
        android:previewImage="@drawable/{프리뷰 이미지}" />

    하는 김에, 같은 파일에서 Quick Circle clock 으로 카테고리를 지정하죠.

    <appwidget-provider xmlns:android="http://schemas.android.com/apk/res/android"
        ...
        android:widgetCategory="0x200"
        android:previewImage="@drawable/{프리뷰 이미지}" />
  5. 빌드하고 설치한 후, Quick Circle 에서 시계 화면 꾹 눌러 시계 선택 모드로 간 다음, 설정하면 끝.

 

Canvas로 그리는 Watch face 포팅하기

이미지를 사용하지 않고, Canvas에 직접 그림을 그려 만든 watch face도 있습니다. 혹은 이미지를 사용하긴 하지만, 초 바늘까지 보여주거나, 시계에 다른 정보(날짜나 날씨 같은?)를 추가하고 싶은 경우도 있겠죠.

다행히도, 안드로이드 위젯 역시, ImageView를 사용하여 Canvas 에 그리기를 할 수 있습니다. 안드로이드 위젯에 Canvas 처리 부분만 만들어놓고, watch face의 기존 코드를 복사해오면 충분히 Clock 위젯으로 만들 수 있을 것 같아요. 물론 몇 부분 자잘한 수정이 필요하지만...

우선 watch face는 CanvasWatchFaceService.Engine에서 드로잉을 처리하는데, 이 클래스에는, 기본적으로 1분마다 발생하는 time tick, ambient등의 모드 변경이나 Visibility 변경을 알려주는 callback 함수가 있습니다. 반면 clock face를 만드는 AppWidgetProvider에서는 time tick이나 visibility 변경에 대한 지원이 없기 때문에, 직접 구현해야 합니다. ambient 모드나 visibility 가 hidden인 상태에서는 시분초 중 몇 가지를 화면에 그리지 않게 함으로써 배터리를 절약할 수 있으므로, clock face에도 구현해두면 좋을 것 같아요.

WatchFaceService.Engine의 시계 구현을 AppWidgetProvider의 시계 구현으로 바꾸기 위해서, 아래와 같이 처리합니다.

 

Canvas를 위한 레이아웃 만들기 (+ 기본 설정)

  1. AppWidget 프로젝트를 하나 만듭니다.
  2. res/layout/{시계 레이아웃 파일}.xml 을 열어, 루트 레이아웃 밑에 다음처럼 입력합니다.

    <ImageView
        android:id="@+id/clockface"
        android:layout_width="match_parent"
        android:layout_height="match_parent"
    />
  3. res/xml/{App widget metadata 파일}.xml 을 열어 아래처럼 프리뷰 이미지와 카테고리를 설정합니다.

    <appwidget-provider xmlns:android="http://schemas.android.com/apk/res/android"
        ...
        android:widgetCategory="0x200"
        android:previewImage="@drawable/{프리뷰 이미지}" />

Watch face 코드 복사하기

  1. Watch face의 CanvasWatchFaceService와 CanvasWatchFaceService.Engine에 정의된 멤버 변수들을 모두 AppWidgetProvider의 멤버 변수로 복사합니다.
    아래는 여기서 배포하는 Watch face 샘플 중 AnalogWatchFaceService를 포팅한 코드입니다.

    public class AnalogWatchFaceProvider extends AppWidgetProvider {
        private static final String TAG = "AnalogWatchFaceService";
        private static final long INTERACTIVE_UPDATE_RATE_MS = TimeUnit.SECONDS.toMillis(1);
        static final int MSG_UPDATE_TIME = 0;
        static final float TWO_PI = (float) Math.PI * 2f;
     
        Bitmap mBackgroundBitmap;
        Bitmap mBackgroundScaledBitmap;
        Paint mHourPaint;
        Paint mMinutePaint;
        Paint mSecondPaint;
        Paint mTickPaint;
        boolean mMute;
        Calendar mCalendar;
     
        final Handler mUpdateTimeHandler = new Handler() {
            @Override
            public void handleMessage(Message message) {
                ... (중략)
            }
        };
     
        final BroadcastReceiver mTimeZoneReceiver = new BroadcastReceiver() {
            @Override
            public void onReceive(Context context, Intent intent) {
                ... (중략)
            }
        };
        boolean mRegisteredTimeZoneReceiver = false;
    }
  2. Context를 저장하기 위한 멤버 변수를 선언합니다.

    public class AnalogWatchFaceProvider extends AppWidgetProvider {
       ... (중략)
       private Context mContext;
    }
  3. Watch face의 CanvasWatchFaceService.Engine의 onCreate() 메소드의 코드를 AppWidgetProvider의 initialize() 메소드 (새로 생성)로 복사합니다. 
    이 때 Watch face에만 적용되는 코드들, 예를 들어 super.onCreate()나 setWatchFaceStyle() 등은 삭제합니다.
  4. Watch face의 CanvasWatchFaceService.Engine의 onDestroy() 메소드의 코드를 AppWidgetProvider의 onDisabled() 메소드로 복사합니다.
    Watch face에만 적용되는 코드들, 예를 들어 super.onDestroy() 등은 삭제합니다.
  5. Watch face의 CanvasWatchFaceService.Engine의 onDraw() 메소드를 AppWidgetProvider의 멤버 메소드로 통째로 복사한 후, 이름을 draw()로 바꿉니다.
    여기서 @override 태그와, 파라미터들을 삭제합니다. Modifier도 public에서 private으로 수정합니다.

    //@override (삭제)
    //public void onDraw(Canvas canvas, Rect bounds) { (삭제)
    private void draw() {  // 추가
        int width = bounds.width();
        int height = bounds.height();
        ... (하략)
    }
  6. 파라미터였던 Canvas와 Rect를 만들어줘야 합니다.
    Canvas는 layout에 지정했던 ImageView에서부터 만들고, Rect는 사용하고자하는 크기를 넣어서 만들겠습니다. 크기는 적당히 지정하면 돼요, 한 500~1000 정도면 될 것 같습니다.

    final static int mSize = 500;
    private void draw() {  // 추가
        Rect bounds = new Rect(0, 0, mSize, mSize);     // 크기 지정
        Bitmap bitmap = Bitmap.createBitmap(mSize, mSize, Bitmap.Config.ARGB_8888);  // Canvas로 사용할 비트맵을 크기에 맞추어 생성
        Canvas canvas = new Canvas(bitmap);             // 생성한 비트맵으로부터 Canvas 생성
        
        int width = bounds.width();
        int height = bounds.height();
        ... (하략)
    }
  7. Watch face의 CanvasWatchFaceService.Engine의 onTimeTick() 메소드를 AppWidgetProvider의 멤버 메소드로 통째로 복사합니다.

 

기타 코드 복사하기

위에서 언급한 코드 외에, watch face에서 추가적으로 구현한 내용이 있을 거예요. 화면 출력에 필요한 메소드나 추가 기능, 계산 로직 같은 것들이죠.

예를 들면, 안드로이드 웨어의 watch face 샘플들도 Time zone 변경 처리 코드가 있고, CalendarWatchFaceService에는 일정 정보를 가져오는 LoadMeetingsTask라는 클래스가 추가 구현되어 있습니다.

이런 코드들은 그대로 clock face에 복사한 후 상황에 따라 수정합니다.

 

위젯에 맞게 코드 수정하기

  1. AppWidgetProvider의 onEnabled() 메소드에서 아래처럼 Context를 저장하고, 초기화 코드를 실행하게 합니다.

    @Override
    public void onEnabled(Context context) {
        mContext = context;     // 컨텍스트 저장
        initialize();           // 초기화
        ...(하략)
    }
  2. onUpdate() 메소드에서 아래와 같이 Context를 저장하고 초기화 합니다. (위젯이 이미 추가된 경우 onEnabled()가 호출되지 않기 때문에, onUpdate() 에서 기본 작업을 처리해야 해요.)

    @Override
    public void onUpdate(Context context, AppWidgetManager appWidgetManager, int[] appWidgetIds) {
        if( mContext == null ) {
            mContext = context;
            initialize();
        }
        ...(하략)
    }
  3. 전체 코드에서 다음과 같이 변경합니다.
    invalidate() -> draw()
    AnalogWatchFaceService.this -> mContext

 

주기적으로 화면 업데이트 하기

 안드로이드 위젯에서는 1분(AnalogClock을 사용하면 자동으로 1분마다 시계 바늘이 업데이트 됩니다), 혹은 1초 마다 호출되는 타이머를 직접 구현해야 합니다. Handler를 쓰거나, AlarmManager를 쓰거나 CountDownTimer를 쓰는 등 여러 가지 방법이 있을 수 있습니다.

아래는 Handler를 사용한 예제입니다. (Android wear의 watch face 샘플에서 가져와서 interval만 수정)

private static final long INTERACTIVE_UPDATE_RATE_MS = TimeUnit.MINUTES.toMillis(1);    // 1분 값
private boolean isRunning = false;                      // 타이머 시작 여부
final Handler mUpdateTimeHandler = new Handler() {     // 타이머 선언
        @Override
        public void handleMessage(Message message) {
            switch (message.what) {
                case MSG_UPDATE_TIME:
                    if (Log.isLoggable(TAG, Log.VERBOSE)) {
                        Log.v(TAG, "updating time");
                    }
 
                    draw();
                    long timeMs = System.currentTimeMillis();
                    long delayMs = INTERACTIVE_UPDATE_RATE_MS - (timeMs % INTERACTIVE_UPDATE_RATE_MS);
                    mUpdateTimeHandler.sendEmptyMessageDelayed(MSG_UPDATE_TIME, delayMs);   // 1분마다 메시지 전달
                    break;
            }
        }
    };
 
private void updateTimer() {            // 타이머 시작 메소드
    mUpdateTimeHandler.removeMessages(MSG_UPDATE_TIME);
    mUpdateTimeHandler.sendEmptyMessage(MSG_UPDATE_TIME);
    isRunning = true;                   // 타이머 시작 표기
}
 
@Override
public void onEnabled(Context context) {
    ...(상략)
    updateTimer();                      // 위젯이 처음 생성될 때 타이머 시작
}
 
@Override
public void onUpdate(Context context, AppWidgetManager appWidgetManager, int[] appWidgetIds) {
    if(!isRunning)
        updateTimer();                 // 위젯 업데이트 주기마다 타이머 체크 후 시작 (onEnabled()가 호출되지 않을 때를 대비)
    ...(하략)
}


이렇게 해서 타이머가 구현되면 그 타이머가 울릴 때, draw() 메소드를 호출하게 합니다.

단, 안드로이드 위젯의 특성 상, AppWidgetProvider 내에서 구현한 타이머는 영원히(question) 유지되지는 않기 때문에, 반드시 Service를 따로 만든 후 타이머를 구현해야 합니다.

Service를 사용하여 안드로이드 위젯을 업데이트 하는 방법은 여기를 참고하세요!

Android wear의 샘플 시계를 Quick Circle로 가져온 모습입니다. 

XPosed 시계 테마를 Quick Circle clock face로

XPosed는 안드로이드 시스템이나 앱 설정을 바꿔주는 프레임워크입니다. XPosed 모듈 중에는 Quick Circle의 clock을 바꿔주는 Quick Circle themer도 있는데, 이 덕분에 사용자들은 Quick Circle이 custom clock 을 지원하기 전에 다양한 시계 화면을 사용할 수 있었죠.

다만 루팅을 해야 하기 때문에 모든 사용자가 쓸 수는 없다는 단점이 있습니다. 이 다양한 XPosed의 Quick Circle 테마를 Quick Circle clock face로 변환할 수 있다면 누구나 사용할 수 있는 Quick Circle clock face가 많아질 거예요.

Quick Circle Themer의 시계 테마를 다운로드 하고 압축을 풀면 다음과 같은 파일들이 들어 있습니다.

  • clock.xml: 설정 정보 파일
  • preview.png: 시계 프리뷰 이미지
  • b2_quickcircle_analog_style03_hour.png: 시 바늘 이미지
  • b2_quickcircle_analog_style03_minute.png: 분 바늘 이미지
  • b2_quickcircle_analog_style03_second.png: 초 바늘 이미지
  • b2_quickcircle_analog_style03_bg.png: 배경 이미지


이 파일들을 사용하여 안드로이드 시계 위젯을 만들면 XPosed의 Quick Circle 시계 테마를 (아주 똑같지는 않지만) 사용할 수 있습니다.

XPosed Quick Circle 시계 테마는 동일한 스타일을 사용하기 때문에, 테마 정보를 읽어 위젯을 만들게 하면 테마가 달라져도 코딩을 하지 않고 재활용할 수 있습니다.

아래는 테마 로더의 소스 코드입니다. (업데이트 용 Service를 만들고 그 안에서 Handler를 사용하여 1초 타이머를 만들었는데요, 개인취향에 따라 바꿔도 됩니다.)

 Click here to expand...
package example.com.circleclockface;
 
import android.app.Service;
import android.appwidget.AppWidgetManager;
import android.appwidget.AppWidgetProvider;
import android.content.BroadcastReceiver;
import android.content.ComponentName;
import android.content.Context;
import android.content.Intent;
import android.content.IntentFilter;
import android.content.res.AssetManager;
import android.content.res.Resources;
import android.graphics.Bitmap;
import android.graphics.Canvas;
import android.graphics.Color;
import android.graphics.Paint;
import android.graphics.drawable.Drawable;
import android.os.Handler;
import android.os.IBinder;
import android.os.Message;
import android.view.View;
import android.widget.RemoteViews;
 
import org.xmlpull.v1.XmlPullParser;
import org.xmlpull.v1.XmlPullParserException;
import org.xmlpull.v1.XmlPullParserFactory;
 
import java.io.IOException;
import java.io.InputStream;
import java.util.Calendar;
import java.util.Locale;
import java.util.TimeZone;
import java.util.concurrent.TimeUnit;
 
import static java.util.Calendar.*;
 
/**
 * Created by jeongeun.jeon on 2015-07-31.
 */
public class XPosedClockThemeLoader extends AppWidgetProvider {
    private static final String TAG = "XPosedClockThemeLoader";
 
    private static final long INTERACTIVE_UPDATE_RATE_MS = TimeUnit.SECONDS.toMillis(1);
    private Context mContext = null;
 
//    private static boolean isRunning = false;
    private String[] file = new String[4];
    private int mXPos = 0, mYPos = 0;
    private boolean isDayShown = true;
    private String mTextColor = "white";
 
 
    @Override
    public void onEnabled(Context context) {
        super.onEnabled(context);
        mContext = context;
        // initialize data when the first widget is added
        initialize();
    }
 
    @Override
    public void onDisabled(Context context) {
        super.onDisabled(context);
        // stop update service
        startService(false);
    }
 
    private void initialize() {
        // load clock.xml <- this file is the configuration file of XPosed Quick Circle theme
        loadConfigXML();
 
        // start update service
        startService(true);
    }
 
    private void startService(boolean start) {
        Intent intent = new Intent(mContext, UpdateService.class);
        // set configurations
        intent.putExtra("files", file);
        intent.putExtra("isDayShown", isDayShown);
        intent.putExtra("textColor", mTextColor);
        if (start)
            mContext.startService(intent);
        else
            mContext.stopService(intent);
    }
 
    private boolean loadConfigXML() {
        boolean result = false;
 
        AssetManager am = mContext.getAssets();
        try {
            InputStream in = am.open("clock.xml");
 
            XmlPullParserFactory xmlFactoryObject = XmlPullParserFactory.newInstance();
            XmlPullParser myParser = xmlFactoryObject.newPullParser();
 
            myParser.setFeature(XmlPullParser.FEATURE_PROCESS_NAMESPACES, false);
            myParser.setInput(in, null);
 
            int fileIndex = 0;
 
            try {
                int event = myParser.getEventType();
                String text = null;
                while (event != XmlPullParser.END_DOCUMENT) {
                    String name = myParser.getName();
 
                    switch (event) {
                        case XmlPullParser.START_TAG:
                            break;
                        case XmlPullParser.TEXT:
                            text = myParser.getText();
                            break