Worldolio is an application that enables you to keep track of various geographical information for cities around the world. It was a collaborative project that was started 20 years ago but we have not really updated it since 2008. A while ago I did produce a package available on Chocolatey to enable the 2008 build to be easily installed.
I have been away for a number of months but on my return I managed to find some time to look into producing an update for the time zone data. The whole build mechanism was last run on Windows XP so it was a bit of challenge working out how to produce the update and documenting how it can be done in future.
In the 15 years since the last release there have been quite a number of changes to the time zone for the cities in Worldolio. The changes include the Russian Federation dropping DST and Turkey changing time zone.
Added new timezones:
Updated timezones:
Removed timezones:
Corrected timezones for: Almaty, Ankara, Bishkek, Damascus, Dhaka, Havana, Istanbul, Khartoum, Minsk, Port-au-Prince, Tripoli, Volgograd, Whitehorse, Yerevan, Kingston, Grand Turk
I have updated Windows desktop application and its chocolatey package as well as the web site version.
Upgrading Worldolio from chocolatey is done by running choco upgrade worldolio
in Powershell like this (you need to run the upgrade as an administrator)
Windows PowerShell
Copyright (C) 2016 Microsoft Corporation. All rights reserved.
PS C:\Data> choco list --local-only
Chocolatey v0.10.15
chocolatey 0.10.15
podcastutilities 3.1.0.0
podcastutilities-core 3.1.0.0
worldolio 2.0.0.0
4 packages installed.
PS C:\Data> choco upgrade worldolio
Chocolatey v0.10.15
Upgrading the following packages:
worldolio
By upgrading you accept licenses for the packages.
You have worldolio v2.0.0.0 installed. Version 2.0.1.0 is available based on your source(s).
Progress: Downloading worldolio 2.0.1.0... 100%
worldolio v2.0.1.0 [Approved]
worldolio package files upgrade completed. Performing other installation steps.
The package worldolio wants to run 'chocolateyinstall.ps1'.
Note: If you don't run this script, the installation will fail.
Note: To confirm automatically next time, use '-y' or consider:
choco feature enable -n allowGlobalConfirmation
Do you want to run the script?([Y]es/[A]ll - yes to all/[N]o/[P]rint): y
Extracting C:\ProgramData\chocolatey\lib\worldolio\tools\\Worldolio.zip to C:\ProgramData\worldolio...
C:\ProgramData\worldolio
Added C:\ProgramData\chocolatey\bin\worldolio.exe shim pointed to 'c:\programdata\worldolio\worldolio.exe'.
The upgrade of worldolio was successful.
Software installed to 'C:\ProgramData\worldolio'
Chocolatey upgraded 1/1 packages.
See the log for details (C:\ProgramData\chocolatey\logs\chocolatey.log).
PS C:\Data>
PS C:\Data> choco list --local-only
Chocolatey v0.10.15
chocolatey 0.10.15
podcastutilities 3.1.0.0
podcastutilities-core 3.1.0.0
worldolio 2.0.1.0
4 packages installed.
PS C:\Data>
Well it certainly wil be a change. I stared work in 1987, I started this blog in 2008. The initial idea was to have a place where I could keep notes and information that I would otherwise forget It also was potentially a place to show off my abilities to potential employers as well as announce developments in my personal software projects for pretty much the same reason. I remember when I started the blog reading other developer’s experiences in creating content, one of the pieces of advice that stuck in my mind was that I should decide on the minimum amount of content I wanted to write, an stick to it.
In my head I decided that I would write once a month. This kept the blog alive but it also meant that I had to achieve things to write in the blog, learning things or producing new releases of my software projects. So a win - win activity.
I took stock after ten years and felt that the blog was achieving what I intended. Now I am retired I think I need to reevaluate the objectives of this blog. I am still keen to carry on learning and plan more personal project work so I anticipate still producing content. However, one of the things I am learning about being retied is the joy of being able to set my own deadlines and not needing to be constrained by my employment. With that in mind I will still produce content butI no longer feel that it has to be every month.
]]>When I wrote the previous post I was using VS2017 which would not run any of the tests that targetted .NETFramework 3.5, I had to use the stand-alone NUnit runner. Now with VS2022 the picture is better as VS will run the old .NETFramework tests. However we would like to be able to run the tests for PodcastUtilities.Common.DLL
on .NETFramework as well as .NETCore, after all the assembly can target both platforms.
So, over the last month we have taken the existing old RhinoMock tests in PodcastUtilities.Common.Tests
and produced a new PodcastUtilities.Common.Multiplatform.Tests
which has all the original unit tests that can be run on .NETCore and .NETFramework.
Obviously I did what most people do and selected the best known mocking framework. Also it must be said I have used Moq in the past. Moq supports both .NETFramework (oldest version 4.6.2) as well as .NETCore (the oldest version I could get to run in VS2022 was 3.1)
There were approximately 500 tests to migrate. It was pretty straightforward, some tests made little use of mocks and most of the rest were ported like this
As an example this is a test written using RhinoMocks
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
[TestFixture]
public abstract class WhenTestingBehaviour
{
/// <summary>
/// Seal the method so it can not be overriden. We want all _context to be
/// set in the <see cref="GivenThat" /> method.
/// </summary>
[SetUp]
public void SetUp()
{
GivenThat();
When();
}
/// <summary>
/// Set up the _context of the test.
/// </summary>
protected virtual void GivenThat()
{
}
/// <summary>
/// Invoke the action being tested.
/// </summary>
protected abstract void When();
protected TM GenerateMock<TM>()
where TM : class
{
return MockRepository.GenerateMock<TM>();
}
}
public abstract class WhenTestingTheDownloader : WhenTestingBehaviour
{
protected Downloader FeedDownloader { get; set; }
protected IWebClient WebClient { get; set; }
protected IPodcastFeedFactory FeedFactory { get; set; }
protected Uri Address { get; set; }
protected IPodcastFeed Feed { get; set; }
protected Stream StreamData { get; set; }
protected override void GivenThat()
{
base.GivenThat();
Address = new Uri("http://localhost/fred");
WebClient = GenerateMock<IWebClient>();
FeedFactory = GenerateMock<IPodcastFeedFactory>();
FeedDownloader = new Downloader(WebClient,FeedFactory);
StreamData = new MemoryStream();
WebClient.Stub(client => client.OpenRead(Address)).Return(StreamData);
}
}
public class WhenTestingTheDownloaderInRss : WhenTestingTheDownloader
{
protected override void When()
{
Feed = FeedDownloader.DownloadFeed(PodcastFeedFormat.RSS,Address, null);
}
[Test]
public void ItShouldDownloadTheFeed()
{
WebClient.AssertWasCalled(c => c.OpenRead(Address));
}
[Test]
public void ItShouldReturnAFeed()
{
FeedFactory.AssertWasCalled(f => f.CreatePodcastFeed(PodcastFeedFormat.RSS, StreamData, null));
}
}
As you can see converting it to Moq is really just about translating syntax the meaning of the test is the same.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
public abstract class WhenTestingBehaviour
{
/// <summary>
/// Seal the method so it can not be overriden. We want all context to be
/// set in the <see cref="GivenThat" /> method.
/// </summary>
[SetUp]
public void Setup()
{
GivenThat();
When();
}
/// <summary>
/// Set up the context of the test.
/// </summary>
protected virtual void GivenThat()
{
}
/// <summary>
/// Invoke the action being tested.
/// </summary>
protected abstract void When();
protected Mock<MOCKTYPE> GenerateMock<MOCKTYPE>()
where MOCKTYPE : class
{
return new Mock<MOCKTYPE>(MockBehavior.Loose);
}
protected Mock<MOCKTYPE> GenerateStrictMock<MOCKTYPE>()
where MOCKTYPE : class
{
return new Mock<MOCKTYPE>(MockBehavior.Strict);
}
}
public abstract class WhenTestingTheDownloader : WhenTestingBehaviour
{
protected Downloader FeedDownloader { get; set; }
protected Mock<IWebClient> WebClient { get; set; }
protected Mock<IPodcastFeedFactory> FeedFactory { get; set; }
protected Uri Address { get; set; }
protected IPodcastFeed Feed { get; set; }
protected Stream StreamData { get; set; }
protected override void GivenThat()
{
base.GivenThat();
Address = new Uri("http://localhost/fred");
WebClient = GenerateMock<IWebClient>();
FeedFactory = GenerateMock<IPodcastFeedFactory>();
FeedDownloader = new Downloader(WebClient.Object, FeedFactory.Object);
StreamData = new MemoryStream();
WebClient.Setup(client => client.OpenRead(Address)).Returns(StreamData);
}
}
public class WhenTestingTheDownloaderInRss : WhenTestingTheDownloader
{
protected override void When()
{
Feed = FeedDownloader.DownloadFeed(PodcastFeedFormat.RSS, Address, null);
}
[Test]
public void ItShouldDownloadTheFeed()
{
WebClient.Verify(c => c.OpenRead(Address));
}
[Test]
public void ItShouldReturnAFeed()
{
FeedFactory.Verify(f => f.CreatePodcastFeed(PodcastFeedFormat.RSS, StreamData, null));
}
}
There were a few complex tests, for example this one needed to ensure that the calls happened in a specific order
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
public class WhenThereAreSomePodcastsContainingFilesNeedingSorting : WhenTestingThePlaylistGenerator
{
protected MockRepository mocks = new MockRepository();
protected override void GivenThat()
{
Playlist = mocks.DynamicMock<IPlaylist>();
base.GivenThat();
Podcasts.Clear();
Podcasts.Add(new PodcastInfo(ControlFile) { Folder = "Hanselminutes" });
Podcasts.Add(new PodcastInfo(ControlFile) { Folder = "This Developers Life" });
Podcasts[0].Pattern.Value = "*.mp3";
Podcasts[1].Pattern.Value = "*.wma";
var podcastFiles1 = new List<IFileInfo> {GenerateMock<IFileInfo>(), GenerateMock<IFileInfo>()};
podcastFiles1[0].Stub(f => f.FullName).Return(@"c:\destination\Hanselminutes\001.mp3");
podcastFiles1[1].Stub(f => f.FullName).Return(@"c:\destination\Hanselminutes\002.mp3");
var podcastFiles2 = new List<IFileInfo> {GenerateMock<IFileInfo>(), GenerateMock<IFileInfo>(), GenerateMock<IFileInfo>()};
// add them so they need sorting
podcastFiles2[0].Stub(f => f.FullName).Return(@"c:\destination\This Developers Life\997.wma");
podcastFiles2[1].Stub(f => f.FullName).Return(@"c:\destination\This Developers Life\999.wma");
podcastFiles2[2].Stub(f => f.FullName).Return(@"c:\destination\This Developers Life\998.wma");
Finder.Stub(f => f.GetFiles(@"c:\destination\Hanselminutes", "*.mp3"))
.Return(podcastFiles1);
Finder.Stub(f => f.GetFiles(@"c:\destination\This Developers Life", "*.wma"))
.Return(podcastFiles2);
using (mocks.Ordered())
{
Playlist.Expect(x => x.AddTrack(@".||Hanselminutes||001.mp3")).Return(true);
Playlist.Expect(x => x.AddTrack(@".||Hanselminutes||002.mp3")).Return(true);
Playlist.Expect(x => x.AddTrack(@".||This Developers Life||997.wma")).Return(true);
Playlist.Expect(x => x.AddTrack(@".||This Developers Life||998.wma")).Return(true);
Playlist.Expect(x => x.AddTrack(@".||This Developers Life||999.wma")).Return(true);
}
Playlist.Replay();
}
protected override void When()
{
PlaylistGenerator.GeneratePlaylist(ControlFile,false);
}
[Test]
public void ItShouldAddAllTheTracksForEachPodcastInTheCorrectOrder()
{
Playlist.VerifyAllExpectations();
}
}
The syntax changes for this change were more divergent but the meaning is still recognisable
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
public class WhenThereAreSomePodcastsContainingFilesNeedingSorting : WhenTestingThePlaylistGenerator
{
protected Mock<IPlaylist> StrictPlaylist { get; set; }
protected Mock<IFileInfo> _file1;
protected Mock<IFileInfo> _file2;
protected Mock<IFileInfo> _file3;
protected Mock<IFileInfo> _file4;
protected Mock<IFileInfo> _file5;
protected override void GivenThat()
{
base.GivenThat();
StrictPlaylist = GenerateStrictMock<IPlaylist>();
Factory.Setup(factory => factory.CreatePlaylist(It.IsAny<PlaylistFormat>(), It.IsAny<string>()))
.Returns(StrictPlaylist.Object);
Podcasts.Clear();
Podcasts.Add(new PodcastInfo(ControlFile.Object) { Folder = "Hanselminutes" });
Podcasts.Add(new PodcastInfo(ControlFile.Object) { Folder = "This Developers Life" });
Podcasts[0].Pattern.Value = "*.mp3";
Podcasts[1].Pattern.Value = "*.wma";
_file1 = GenerateMock<IFileInfo>();
_file2 = GenerateMock<IFileInfo>();
_file3 = GenerateMock<IFileInfo>();
_file4 = GenerateMock<IFileInfo>();
_file5 = GenerateMock<IFileInfo>();
var podcastFiles1 = new List<IFileInfo> { _file1.Object, _file2.Object };
_file1.Setup(f => f.FullName).Returns(@"c:\destination\Hanselminutes\001.mp3");
_file2.Setup(f => f.FullName).Returns(@"c:\destination\Hanselminutes\002.mp3");
// add them so they need sorting
var podcastFiles2 = new List<IFileInfo> { _file3.Object, _file4.Object, _file5.Object };
_file3.Setup(f => f.FullName).Returns(@"c:\destination\This Developers Life\997.wma");
_file4.Setup(f => f.FullName).Returns(@"c:\destination\This Developers Life\999.wma");
_file5.Setup(f => f.FullName).Returns(@"c:\destination\This Developers Life\998.wma");
Finder.Setup(f => f.GetFiles(@"c:\destination\Hanselminutes", "*.mp3"))
.Returns(podcastFiles1);
Finder.Setup(f => f.GetFiles(@"c:\destination\This Developers Life", "*.wma"))
.Returns(podcastFiles2);
var sequence = new MockSequence();
// Create the expectations, notice that the Setup is called via InSequence
StrictPlaylist.InSequence(sequence).Setup(p => p.AddTrack(@".||Hanselminutes||001.mp3")).Returns(true);
StrictPlaylist.InSequence(sequence).Setup(p => p.AddTrack(@".||Hanselminutes||002.mp3")).Returns(true);
StrictPlaylist.InSequence(sequence).Setup(p => p.AddTrack(@".||This Developers Life||997.wma")).Returns(true);
StrictPlaylist.InSequence(sequence).Setup(p => p.AddTrack(@".||This Developers Life||998.wma")).Returns(true);
StrictPlaylist.InSequence(sequence).Setup(p => p.AddTrack(@".||This Developers Life||999.wma")).Returns(true);
StrictPlaylist.SetupGet(p => p.NumberOfTracks).Returns(5);
StrictPlaylist.Setup(p => p.SaveFile(@"c:\file.tmp"));
}
protected override void When()
{
PlaylistGenerator.GeneratePlaylist(ControlFile.Object, false);
}
[Test]
public void ItShouldAddAllTheTracksForEachPodcastInTheCorrectOrder()
{
// the verification order does not matter - its the setup order that counts
StrictPlaylist.Verify(p => p.AddTrack(@".||Hanselminutes||001.mp3"), Times.Once());
StrictPlaylist.Verify(p => p.AddTrack(@".||Hanselminutes||002.mp3"), Times.Once());
StrictPlaylist.Verify(p => p.AddTrack(@".||This Developers Life||997.wma"), Times.Once());
StrictPlaylist.Verify(p => p.AddTrack(@".||This Developers Life||998.wma"), Times.Once());
StrictPlaylist.Verify(p => p.AddTrack(@".||This Developers Life||999.wma"), Times.Once());
}
}
PodcastUtilities.Common.DLL
, the assembly under test, is slightly different when built for .NETFramework. .NETFramework supports MTP via P/Invoke whereas the .NETCore build does not. This means that there are between 20 and 30 extra tests for the .NETFramework version. We would like those tests to be run when the tests are run on .NETFramework.
Initially I created a test assembly in VS2022 that targetted .NETCore. To convert it to target both .NETCore and .NETFramework the .csproj
file was changed like this
Change
<TargetFramework>netcoreapp3.1</TargetFramework>
to be
<TargetFrameworks>net462;netcoreapp3.1</TargetFrameworks>
There may be a way of doing this in UI but I just edited the .csproj
file.
We also added the following section into the .csproj
file.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
<!-- .NET Core 3.1 references, compilation flags and build options -->
<PropertyGroup Condition=" '$(TargetFramework)' == 'netcoreapp3.1'">
<DefineConstants>NETCORE;NETCORE3_1</DefineConstants>
</PropertyGroup>
<ItemGroup Condition=" '$(TargetFramework)' == 'netcoreapp3.1'">
<Compile Remove=".\Platform\FileSystemAwareFileUtilitiesTests\**\*.cs" Label="NO_MTP" />
<Compile Remove=".\Platform\MtpTests\**\*.cs" Label="NO_MTP" />
</ItemGroup>
<!-- .NET references, compilation flags and build options -->
<PropertyGroup Condition=" '$(TargetFramework)' == 'net462'">
<DefineConstants>NET462;NETFULL</DefineConstants>
</PropertyGroup>
In the .NETCore target we remove all the tests from the FileSystemAwareFileUtilitiesTests
and the MtpTests
folders, as those tests cannot be run (or even compiled) on that platform as they are MTP tests.
By adding the DefineConstants
we can write code for a specific platform in C# like this
1
2
3
#if NETFULL
CodeThatCanOnlyRunOnWindowsDotNetFramework()
#endif
You can see the complete code for the project including the tests in Github
]]>To address these issues I have
/sdcard/Android/data/net.derekwilson.recommender
I have also made a few changes to try and isolate the app from Googles endless churn
I also made a few minor changes
The source code is in bitbucket.
]]>This post is part of a series of posts exploring writing apps for Android using Xamarin Android.
I have found that the extra complexity of using fragments, with their similar but subtly different lifecycle from activities, to not really be worth the effort. So I tend to compose activities from custom views instead. One view I have used in multiple apps is a custom view to show the progress of a task.
As we have seen in Xamarin Android, the view layouts use the same XML as writing apps using Kotlin of Java and this custom view is no excpetion. The layout for the control looks like this
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
<LinearLayout
android:orientation="vertical"
android:layout_width="match_parent"
android:layout_height="wrap_content"
xmlns:android="http://schemas.android.com/apk/res/android">
<TextView
android:id="@+id/progress_bar_message"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:paddingTop="5dp"
android:textAlignment="center"
android:background="@color/primary"
android:alpha="0.8"
android:textColor="@color/background"
android:text="@string/placeholder"
android:gravity="center_horizontal" />
<ProgressBar
android:id="@+id/indeterminateBar"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:paddingTop="5dp"
android:paddingBottom="5dp"
android:background="@color/primary"
android:alpha="0.5"
android:indeterminate="true"
/>
<ProgressBar
android:id="@+id/steppedBar"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:background="@color/primary"
android:alpha="0.8"
android:max="10"
android:progress="5"
style="?android:attr/progressBarStyleHorizontal"
android:indeterminate="false"
/>
</LinearLayout>
I used the same layout when implementing the view in Kotlin and C#
The implementation in Kotlin is pretty standard android development
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
class ProgressSpinnerView : LinearLayout {
@BindView(R.id.progress_bar_message)
lateinit var messageView: TextView
@BindView(R.id.indeterminateBar)
lateinit var indeterminateBar: ProgressBar
@BindView(R.id.steppedBar)
lateinit var steppedBar: ProgressBar
constructor(context: Context) : super(context) {
init(context, null, 0)
}
constructor(context: Context, attrs: AttributeSet) : super(context, attrs) {
init(context, attrs, 0)
}
constructor(context: Context, attrs: AttributeSet, defStyle: Int) : super(context, attrs, defStyle) {
init(context, attrs, defStyle)
}
var message: String? = null
set(value) {
field = value
messageView.text = value
}
var max: Int = 1
set(value) {
field = value
steppedBar.max = value
}
var progress: Int = 0
set(value) {
field = value
steppedBar.progress = value
}
private fun init(context: Context, attrs: AttributeSet?, defStyle: Int) {
val view = inflateView(context)
ButterKnife.bind(this, view)
loadAttributes(attrs, defStyle)
}
private fun loadAttributes(attrs: AttributeSet?, defStyle: Int) {
val a = context.obtainStyledAttributes(
attrs, R.styleable.ProgressSpinnerView, defStyle, 0)
message = a.getString(R.styleable.ProgressSpinnerView_message)
a.recycle()
}
private fun inflateView(context: Context): View {
val inflater: LayoutInflater = context.getSystemService(Context.LAYOUT_INFLATER_SERVICE) as LayoutInflater
return inflater.inflate(R.layout.view_progress_spinner, this, true)
}
fun slideDown(indeterminateProgress: Boolean) {
indeterminateBar.visibility = if (indeterminateProgress) View.VISIBLE else View.GONE
steppedBar.visibility = if (indeterminateProgress) View.GONE else View.VISIBLE
visibility = View.VISIBLE
val animate = TranslateAnimation(
0f, // fromXDelta
0f, // toXDelta
-height.toFloat(), // fromYDelta
0f) // toYDelta
animate.duration = 500
clearAnimation()
startAnimation(animate)
}
fun slideUp() {
visibility = View.GONE
val animate = TranslateAnimation(
0f, // fromXDelta
0f, // toXDelta
0f, // fromYDelta
-height.toFloat()) // toYDelta
animate.duration = 500
clearAnimation()
startAnimation(animate)
}
}
In C# the code is pretty much the same, with the slight differences in the syntax.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
namespace PodcastUtilities.AndroidLogic.CustomViews;
public class ProgressSpinnerView : LinearLayout
{
private TextView messageView;
private ProgressBar indeterminateBar;
private ProgressBar steppedBar;
public ProgressSpinnerView(Context context) : base(context)
{
Init(context, null, 0);
}
public ProgressSpinnerView(Context context, IAttributeSet attrs) : base(context, attrs)
{
Init(context, attrs, 0);
}
public ProgressSpinnerView(Context context, IAttributeSet attrs, int defStyleAttr) : base(context, attrs, defStyleAttr)
{
Init(context, attrs, defStyleAttr);
}
public string Message
{
set
{
messageView.Text = value;
}
}
public int Max
{
set
{
steppedBar.Max = value;
}
}
public int Progress
{
set
{
steppedBar.Progress = value;
}
}
private void Init(Context context, IAttributeSet attrs, int defStyle)
{
var view = InflateView(context);
messageView = FindViewById<TextView>(Resource.Id.progress_bar_message);
indeterminateBar = FindViewById<ProgressBar>(Resource.Id.indeterminateBar);
steppedBar = FindViewById<ProgressBar>(Resource.Id.steppedBar);
LoadAttributes(attrs, defStyle);
}
private void LoadAttributes(IAttributeSet attrs, int defStyle)
{
var a = Context.ObtainStyledAttributes(attrs, Resource.Styleable.ProgressSpinnerView, defStyle, 0);
Message = a.GetString(Resource.Styleable.ProgressSpinnerView_message);
a.Recycle();
}
private View InflateView(Context context)
{
LayoutInflater inflater = context.GetSystemService(Context.LayoutInflaterService) as LayoutInflater;
return inflater.Inflate(Resource.Layout.view_progress_spinner, this, true);
}
public void SlideDown(bool indeterminateProgress)
{
indeterminateBar.Visibility = indeterminateProgress ? ViewStates.Visible : ViewStates.Gone;
steppedBar.Visibility = indeterminateProgress ? ViewStates.Gone : ViewStates.Visible;
this.Visibility = ViewStates.Visible;
var animate = new TranslateAnimation(
0f, // fromXDelta
0f, // toXDelta
-this.Height, // fromYDelta
0f); // toYDelta
animate.Duration = 500;
ClearAnimation();
StartAnimation(animate);
}
public void SlideUp()
{
this.Visibility = ViewStates.Gone;
var animate = new TranslateAnimation(
0f, // fromXDelta
0f, // toXDelta
0f, // fromYDelta
-this.Height); // toYDelta
animate.Duration = 500;
ClearAnimation();
StartAnimation(animate);
}
}
We need to mark the message
property as stylable so it can be set in XML. I do this by creating a file called attrs_progress_spinner_view.xml
in the values
folder of the resources.
1
2
3
4
5
6
<?xml version="1.0" encoding="utf-8"?>
<resources>
<declare-styleable name="ProgressSpinnerView">
<attr name="message" format="string"/>
</declare-styleable>
</resources>
Then to use the custom view we can simply include it in a layout like this
1
2
3
4
5
6
7
8
9
10
<PodcastUtilities.AndroidLogic.CustomViews.ProgressSpinnerView
android:id="@+id/progressBar"
android:layout_width="match_parent"
android:layout_height="wrap_content"
app:layout_constraintTop_toTopOf="parent"
android:elevation="2dp"
android:visibility="gone"
app:message="@string/finding_podcasts_progress"
>
</PodcastUtilities.AndroidLogic.CustomViews.ProgressSpinnerView>
And it looks like this
There is a slight gotcha when using Xamarin Android, according to stackoverflow the XML element name should be the name of the class including the full namespace. Sometimes this needs to be in lower case and sometimes the case needs to match what was specified in the C# class file. I have not worked out the pattern, if you get an inflation error then try playing with the namespace in the XML element.
Also using custom views tends to blow up the designer in Visual Studio.
]]>Its designed to do a simple task
Benefits
PassTheParcel is available on Gooogle Play, it can be side-loaded from the Bitbucket repo, as well as installed from the Amazon Appstore.
The source code is in Bitbucket.
]]>In a previous post we completed all the basic tasks building and publishing an app for Android using Xamarin Android. Part of deploying a mobile app includes the ability to be able to monitor crashes and report on analytics.
To do this monitoring I declared two interfaces one for analytics and one for crashes
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
public interface ICrashReporter
{
void TestReporting();
void LogNonFatalException(Exception ex);
}
public interface IAnalyticsEngine
{
void DownloadFeedEvent(int numberOfItems);
void DownloadSpecificFeedEvent(int numberOfItems, string folder);
void DownloadEpisodeEvent(long sizeInMB);
void DownloadEpisodeCompleteEvent();
void LoadControlFileEvent();
void LifecycleLaunchEvent();
void LifecycleErrorEvent();
void LifecycleErrorFatalEvent();
void GeneratePlaylistEvent(PlaylistFormat format);
void GeneratePlaylistCompleteEvent(int numberOfItems);
void PurgeScanEvent(int numberOfItems);
void PurgeDeleteEvent(int numberOfItems);
}
The automatic choice for most android applications is Google Analytics and Firbase Crashlytics
Adding these to a Xamarin Android application is pretty straightforward. Simply add NuGet package references for Xamarin.Firebase.Analytics
and Xamarin.Firebase.Crashlytics
to the project.
There are instructions for setting up Crashlytics but essentially you create a project in the developer console, download the generated google-services.json
and add the file to your project
1
2
3
<ItemGroup>
<AndroidAsset Include="Assets\NLog.config" />
<GoogleServicesJson Include="google-services.json" />
There is a small gotcha as explained here, but if you add this string to your resources then all should be well.
1
2
3
4
5
6
7
8
<resources>
<!--
Bonkers - but see
https://github.com/a-imai/XamarinCrashlyticsUpgradeSample
https://docs.microsoft.com/en-us/answers/questions/450181/android-firebase-crashlytics-build-id-is-missing.html
-->
<string name="com.google.firebase.crashlytics.mapping_file_id">none</string>
</resources>
The crash reporter looks like this
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
public class CrashlyticsReporter : ICrashReporter
{
private IAnalyticsEngine AnalyticsEngine;
public CrashlyticsReporter(IAnalyticsEngine analyticsEngine)
{
AnalyticsEngine = analyticsEngine;
}
public void LogNonFatalException(Exception ex)
{
var throwable = Java.Lang.Throwable.FromException(ex);
FirebaseCrashlytics.Instance.RecordException(throwable);
AnalyticsEngine?.LifecycleErrorEvent();
}
public void TestReporting()
{
throw new NotImplementedException("Test Crash Reporting");
}
}
The analytics looks like this
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
public class FirebaseAnalyticsEngine : IAnalyticsEngine
{
private Context ApplicationContext;
private IAndroidApplication Application;
public FirebaseAnalyticsEngine(Context applicationContext, IAndroidApplication application)
{
ApplicationContext = applicationContext;
Application = application;
}
private void SendEvent(string eventName, string eventCategory, string eventAction, string eventLabel, string eventValue)
{
var firebaseAnalytics = FirebaseAnalytics.GetInstance(ApplicationContext);
var bundle = new Bundle();
bundle.PutString(FirebaseAnalytics.Param.ItemId, eventCategory);
if (eventAction != null)
{
bundle.PutString(FirebaseAnalytics.Param.ItemName, eventAction);
}
if (eventLabel != null)
{
bundle.PutString(FirebaseAnalytics.Param.ContentType, eventLabel);
}
if (eventValue != null)
{
bundle.PutString(FirebaseAnalytics.Param.Value, eventValue);
}
firebaseAnalytics.LogEvent(eventName, bundle);
}
private const string Seperator = "_";
private const string Category_Lifecycle = "Lifecycle";
private const string Action_Lifecycle_Error = "Lifecycle_Error";
public void LifecycleErrorEvent()
{
SendEvent(
FirebaseAnalytics.Event.SelectContent,
Category_Lifecycle,
Action_Lifecycle_Error,
Action_Lifecycle_Error + Seperator + Application.DisplayVersion,
null
);
}
The crashes appear like this
The stack trace is a little mangled almost certainly caused by passing it from .NET to Java environments
The main issue with using Google Analytics and Firebase Crashlytics is that it ties you into using Google Play Services and that means that you can only run on Google’s Android, Amazon Fire OS and Windows Subsystem for Android are not available without hacking.
Microsoft AppCenter is an free alternative from Microsoft that, amongst other things, has AppCenter Crashes and AppCenter Analytics that are direct replacements that work in all Android forks.
Getting setup is also very simple. Add NuGet package references for Microsoft.AppCenter.Analytics
and Microsoft.AppCenter.Crashes
Then create a new project in the AppCenter Console and then initialise the SDK like this
1
2
3
4
5
6
[Application]
public class AndroidApplication : Application, IAndroidApplication
{
public override void OnCreate()
{
AppCenter.Start(Secrets.APP_CENTER_SECRET, typeof(Analytics), typeof(Crashes));
The crash reporter looks like this
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
public class AppCenterCrashReporter : ICrashReporter
{
private IAnalyticsEngine AnalyticsEngine;
public AppCenterCrashReporter(IAnalyticsEngine analyticsEngine)
{
AnalyticsEngine = analyticsEngine;
}
public void LogNonFatalException(Exception ex)
{
Crashes.TrackError(ex);
AnalyticsEngine?.LifecycleErrorEvent();
}
public void TestReporting()
{
// Note - this will only do anything in a debug build
Crashes.GenerateTestCrash();
}
}
The analytics looks like this
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
public class AppCenterAnalyticsEngine : IAnalyticsEngine
{
private IAndroidApplication Application;
public AppCenterAnalyticsEngine(IAndroidApplication application)
{
Application = application;
}
private const string Event_Lifecycle_Error = "Lifecycle_Error";
private const string Property_Version = "Version";
public void LifecycleErrorFatalEvent()
{
Analytics.TrackEvent(Event_Lifecycle_ErrorFatal, new Dictionary<string, string> {
{ Property_Version, Application.DisplayVersion}
});
}
The crashes appear like this
Both mechanisms work pretty much as you would expect
]]>Last month we published a version of PodcastUtilities for Android phones and made it available through the Amazon App Store. To do this we needed to make some minor changes to the core PodcastUtilities and also fix a few bugs.
This month we have updated both packages for PodcastUtilities, the package for PodcastUtilities on .NET Framework on Windows and the cross platform package for PodcastUtilities on .NET Core to include the changes that were made for the Android version.
After installing chocolatey, to install the cross platform edition of PodcastUtilities run the command
choco install podcastutilities-core
After installing chocolatey, to install the .NET framework version of PodcastUtilities run the command
choco install podcastutilities
For most purposes you probably dont want or need to install both versions.
]]>We did attempt to distribute it via the Google Play Store however as we needed “Manage Storage” permission Google declined saying “The feature you identified that is dependent on this permission does not appear to be critical to the core functionality of your app”, rather than arguing the toss with a Google-bot we decided to publish PodcastUtilities on Amazon App Store as well as making it available to sideload.
The source code is in Github.
]]>In the previous post we completed all the basic tasks building and publishing an app for Android using Xamarin Android. There are some other techniques that android developers tend to use that are slightly different when using C#
In kotlin you will often see click handlers attached to RecyclerView
items by using setOnClickListener
like this
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
override fun onBindViewHolder(holder: RecyclerViewHolder, position: Int) {
val id = presenter.getTrackId(position)
val label = presenter.getTrackLabel(position)
val sublabel = presenter.getTrackSubLabel(position)
holder.bind(label, sublabel)
holder.rowLabelContainer.setOnClickListener {
presenter.trackSelected(id, position)
}
}
override fun trackSelected(trackId: Long, position: Int) {
if (trackId < 0) {
return
}
if (inMultiSelectMode) {
toggleSelection(position)
} else {
view?.navigateToTrack(trackId)
}
}
Although you can (and possibly should) call setOnClickListener(null)
to deregister the event handler most examples dont do this.
There are Xamarin.Android wrappers that mean I could just call setOnClickListener
however while putting together the rest of the app I had decided I preferred to use C# EventHandlers so I needed a mechanism to use them, in C# I wrote this
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
public override void OnBindViewHolder(RecyclerView.ViewHolder holder, int position)
{
RecyclerViewHolder vh = holder as RecyclerViewHolder;
// unsubscribe if it was subscribed before
vh.Container.Click -= Container_Click;
vh.Label.Text = ViewModel.GetLabelForListItem(Items[position]);
vh.CheckBox.Checked = Items[position].Selected;
vh.Container.Tag = position.ToString();
vh.Container.Click += Container_Click;
}
private void Container_Click(object sender, EventArgs e)
{
int position = Convert.ToInt32(((View)sender).Tag.ToString());
Items[position].Selected = !Items[position].Selected;
NotifyItemChanged(position);
ViewModel.SelectionChanged(position);
}
class RecyclerViewHolder : RecyclerView.ViewHolder
{
public View Container { get; private set; }
public TextView Label { get; private set; }
public AppCompatCheckBox CheckBox { get; private set; }
public RecyclerViewHolder(View itemView) : base(itemView)
{
Container = itemView.FindViewById<View>(Resource.Id.purge_row_label_container);
Label = itemView.FindViewById<TextView>(Resource.Id.purge_row_label);
CheckBox = itemView.FindViewById<AppCompatCheckBox>(Resource.Id.purge_row_check);
}
}
The ViewHolder.Container
is just a simple Android View
and I wanted to use its Click
C# EventHandler, I would use standard C# +=
and -=
syntax to add and remove the handler.
Inside the event handler Container_Click
I needed to be able to find the id of the item that was being represented. To do this I just set the Tag
property on the ViewHolder.Container
to be the position in the list and then in the evnt handler I could read the Tag
property and convert it back into the position